use techne_lexicon_check::{current_name, dag_cbor, has_public_home, is_record_type, record_key, record_types};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Deserialize)]
pub struct Record {
pub record: Value,
#[serde(default)]
pub rkey: Option<String>,
#[serde(default)]
pub space: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Write {
pub uri: String,
pub cid: String,
pub collection: String,
pub rkey: String,
pub record: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub space: Option<String>,
pub private: bool,
pub lexicon: bool,
}
#[derive(Debug, Deserialize)]
pub struct DeleteRecord {
#[serde(default)]
pub uri: Option<String>,
#[serde(default)]
pub collection: Option<String>,
#[serde(default)]
pub rkey: Option<String>,
#[serde(default)]
pub space: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Delete {
pub uri: String,
pub collection: String,
pub rkey: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub space: Option<String>,
pub private: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Plan {
pub writes: Vec<Write>,
pub deletes: Vec<Delete>,
}
#[cfg(not(target_arch = "wasm32"))]
pub fn plan_with_deletes(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>) -> Result<Plan, String> {
plan_with_deletes_at(repo, records, deletes, unix_now())
}
pub fn plan_with_deletes_at(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>, now_unix: u64) -> Result<Plan, String> {
let writes = plan_at(repo, records, now_unix)?;
let mut deletes = deletes.into_iter().map(|d| delete(repo, d)).collect::<Result<Vec<_>, _>>()?;
if let Some(both) = deletes.iter().find(|d| writes.iter().any(|w| w.uri == d.uri)) {
return Err(format!("{}: both written and deleted in one put", both.uri));
}
if let Some((_, twice)) = deletes.iter().enumerate().find(|(i, d)| deletes[..*i].iter().any(|e| e.uri == d.uri)) {
return Err(format!("{}: deleted twice in one put", twice.uri));
}
deletes.sort_by_key(|d| d.private);
Ok(Plan { writes, deletes })
}
fn is_nsid(s: &str) -> bool {
let segments: Vec<&str> = s.split('.').collect();
segments.len() >= 3 && segments.iter().all(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
}
fn is_rkey(s: &str) -> bool {
(1..=512).contains(&s.len())
&& s != "."
&& s != ".."
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '~' | '-'))
}
fn delete(repo: &str, DeleteRecord { uri, collection, rkey, space }: DeleteRecord) -> Result<Delete, String> {
let from_uri = |uri: &str| -> Result<(String, String), String> {
let rest = uri.strip_prefix("at://").ok_or_else(|| format!("{uri}: not an at:// URI"))?;
let mut parts = rest.split('/');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(authority), Some(c), Some(k), None) => {
if authority != repo {
return Err(format!("{uri}: not in {repo}'s repo; put deletes only the writer's own records (by DID)"));
}
Ok((c.to_owned(), k.to_owned()))
}
_ => Err(format!("{uri}: expected at://{{repo}}/{{collection}}/{{rkey}}")),
}
};
let (collection, rkey) = match (uri, collection, rkey) {
(Some(uri), None, None) => from_uri(&uri)?,
(None, Some(c), Some(k)) => (c, k),
(Some(uri), Some(c), Some(k)) => {
if from_uri(&uri)? != (c.clone(), k.clone()) {
return Err(format!("{uri}: its uri names another key than {c}/{k}"));
}
(c, k)
}
_ => return Err("a delete names its uri, or its collection and rkey".to_owned()),
};
if !is_nsid(&collection) {
return Err(format!("{collection}: not a collection NSID"));
}
if !is_rkey(&rkey) {
return Err(format!("{rkey}: not a record key"));
}
if let Some(space) = &space {
if !space.starts_with("at://") {
return Err(format!("{space}: a space is an at:// URI"));
}
}
Ok(Delete {
uri: format!("at://{repo}/{collection}/{rkey}"),
private: space.is_some() || (is_ours(&collection) && !has_public_home(current_name(&collection))),
collection,
rkey,
space,
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn plan(repo: &str, records: Vec<Record>) -> Result<Vec<Write>, String> {
plan_at(repo, records, unix_now())
}
pub fn plan_at(repo: &str, records: Vec<Record>, now_unix: u64) -> Result<Vec<Write>, String> {
let mut writes = records.into_iter().map(|r| write(repo, r, now_unix)).collect::<Result<Vec<_>, _>>()?;
writes.sort_by_key(|w| !w.private);
Ok(writes)
}
#[cfg(not(target_arch = "wasm32"))]
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn write(repo: &str, Record { record, rkey, space }: Record, now_unix: u64) -> Result<Write, String> {
let collection = record.get("$type").and_then(Value::as_str).ok_or("a record without a $type")?.to_owned();
let cid = dag_cbor::cid_for_json(&record).map_err(|e| format!("{collection}: {}", e.0))?;
let current = current_name(&collection);
let rkey = match (rkey, record_key(current).and_then(|k| k.strip_prefix("literal:"))) {
(Some(rkey), _) => rkey,
(None, Some(literal)) => literal.to_owned(),
(None, None) => {
let created = record.get("createdAt").and_then(Value::as_str).ok_or_else(|| {
format!("{collection}: no rkey given, and no createdAt to derive one from")
})?;
let at = chrono::DateTime::parse_from_rfc3339(created).map_err(|e| format!("{collection}: createdAt: {e}"))?;
let digest = cid.hash().digest();
techne_lexicon_check::tid(at.timestamp_micros(), u16::from_be_bytes([digest[0], digest[1]]))
}
};
Ok(Write {
uri: format!("at://{repo}/{collection}/{rkey}"),
cid: cid.to_string(),
private: space.is_some() || (is_ours(&collection) && !has_public_home(current)),
lexicon: is_record_type(&collection)
|| crate::renames::current_name_at(&collection, now_unix).is_some_and(is_record_type),
collection,
rkey,
record,
space,
})
}
fn namespace(nsid: &str) -> Option<String> {
let mut segments = nsid.splitn(3, '.');
let (a, b) = (segments.next()?, segments.next()?);
segments.next()?;
Some(format!("{a}.{b}").to_ascii_lowercase())
}
fn private_namespaces() -> &'static [String] {
static NAMESPACES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMESPACES.get_or_init(|| {
let mut out: Vec<String> =
record_types().into_iter().filter(|c| !has_public_home(c)).filter_map(namespace).collect();
out.sort();
out.dedup();
let mut old: Vec<String> = crate::renames::pairs()
.iter()
.filter(|p| namespace(&p.new).is_some_and(|ns| out.contains(&ns)))
.filter_map(|p| namespace(&p.old))
.collect();
out.append(&mut old);
out.sort();
out.dedup();
out
})
}
fn is_ours(collection: &str) -> bool {
is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const RENAMES_JSON: &str = crate::renames::JSON;
const AT: &str = "2026-10-19T12:00:00.000Z";
const NOW: u64 = 1_792_411_200;
fn one(collection: &str, space: Option<&str>) -> Write {
let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
}
fn private_and_public_types() -> (&'static str, &'static str) {
let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
let ns = namespace(private);
let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
(private, public)
}
#[test]
fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
let repo = "did:plc:alice";
assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
let bare = json!({"$type": "x.example.thing", "name": "x"});
let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
assert!(err.contains("no createdAt"), "{err}");
assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
}
fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
let s = |v: Option<&str>| v.map(str::to_owned);
DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
}
#[test]
fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
let repo = "did:plc:alice";
let event = "community.lexicon.calendar.event";
let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
let p = plan_with_deletes(
repo,
vec![],
vec![
del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
],
)
.unwrap();
assert!(p.writes.is_empty());
assert_eq!(
p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
[(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
);
assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
assert_eq!(p.deletes[2].space.as_deref(), Some(space));
}
#[test]
fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
let repo = "did:plc:alice";
let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
assert!(err.contains("not in did:plc:alice's repo"), "{err}");
let both = plan_with_deletes(
repo,
vec![Record { record: event, rkey: Some("k".into()), space: None }],
vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
)
.unwrap_err();
assert!(both.contains("both written and deleted"), "{both}");
assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
let ev = "community.lexicon.calendar.event";
let twice = plan_with_deletes(repo, vec![], vec![del(None, Some(ev), Some("k"), None), del(Some(&format!("at://{repo}/{ev}/k")), None, None, Some("at://did:plc:alice/x.y.space/k"))]).unwrap_err();
assert!(twice.contains("deleted twice"), "{twice}");
for (uri, c, k, space) in [
(Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
(Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
(None, Some("x.y/z"), Some("k"), None),
(None, Some("x.y.z"), Some(".."), None),
(None, Some("x.y.z"), Some("k"), Some("")),
(Some("at://alice.test/x.y.z/k"), None, None, None),
(Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
] {
assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
}
let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
}
#[test]
fn a_type_with_no_lexicon_goes_to_the_users_pds() {
assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
let space = "at://did:plc:alice/com.example.space/self";
assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
let (private, public) = private_and_public_types();
assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
}
#[test]
fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
let (private, public) = private_and_public_types();
assert!(one(private, None).lexicon && one(public, None).lexicon);
assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
}
#[test]
fn an_unknown_type_in_the_platforms_namespace_stays_private() {
let (private, public) = private_and_public_types();
let ns = namespace(private).unwrap();
let old_name = |c: &str| c.replacen(".draft.", ".", 1);
let unknown = [
format!("{private}x"),
old_name(private),
old_name(public),
private.to_ascii_uppercase(),
public.to_ascii_uppercase(),
];
for collection in &unknown {
assert!(!is_record_type(collection), "{collection} has no lexicon");
assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
}
let neighbour = format!("{ns}x.note");
assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
}
fn renames() -> Vec<(String, String)> {
crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
}
#[test]
fn renames_copy_is_the_platforms() {
let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
if let Ok(bytes) = std::fs::read_to_string(&input) {
assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
}
let pairs = renames();
assert!(!pairs.is_empty());
for (old, new) in &pairs {
assert_eq!(current_name(old), new, "the check renames {old} otherwise");
}
let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
let currents: std::collections::BTreeSet<String> =
record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
let mut asked = 0;
for current in ¤ts {
let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
let candidates = tail
.into_iter()
.flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
for name in candidates.chain(std::iter::once(current.clone())) {
asked += 1;
let answered = current_name(&name);
if answered != name {
assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
}
}
}
assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
}
#[test]
fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
.as_object()
.unwrap()
.iter()
.flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
.collect();
let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
let (mut early_seen, mut late_seen) = (false, false);
for (old, new) in renames() {
if !is_record_type(&new) || is_record_type(&old) {
continue;
}
let last = last_of(&old);
early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
let lexicon = |now| {
let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
};
assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
}
assert!(early_seen && late_seen, "both kinds of window are checked");
}
#[test]
fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
let (mut public, mut private, mut literal) = (0, 0, 0);
for (old, _) in renames() {
let current = current_name(&old);
if old == current || !is_record_type(current) {
continue;
}
let w = one(&old, None);
assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
assert!(w.lexicon, "{old}: the platform has {current}");
assert_eq!(w.collection, old, "written under the name given");
if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
assert_eq!(w.rkey, key, "{old}: {current}'s key");
literal += 1;
}
if w.private { private += 1 } else { public += 1 }
}
assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
}
#[test]
fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
let repo = "did:plc:alice";
let (mut public, mut private) = (0, 0);
for (old, _) in renames() {
let current = current_name(&old);
if old == current || !is_record_type(current) {
continue;
}
let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
let d = &p.deletes[0];
assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
if d.private { private += 1 } else { public += 1 }
}
assert!(public > 0 && private > 0, "public {public}, private {private}");
}
#[test]
fn an_unknown_name_in_the_old_namespace_stays_private() {
let renames = renames();
let old = |public: bool| {
renames
.iter()
.find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
.map(|(o, _)| o.clone())
.expect("a renamed type")
};
for old in [old(true), old(false)] {
for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
assert!(p.deletes[0].private, "{collection}: its delete is private too");
}
}
}
}