···11//! SQLite read queries and generated-view builders for the read endpoints.
22//!
33//! Records come from the `record_json` column (written by the projection); the
44-//! required `record` field on thing/model/part views is built from it via
55-//! [`to_data`]. Author handles come from `identities` (refreshed by identity
66-//! events) and profile display fields from `profiles`. Authored image/blob fields
77-//! are deserialized through generated lexicon types and resolved to Bluesky CDN
88-//! URLs in appview response fields; no fake CDN/media data is synthesized.
44+//! required `record` field on thing/model/part views is built from it. Author
55+//! handles come from `identities` (refreshed by identity events) and profile
66+//! display fields from `profiles`. Authored image/blob fields are deserialized
77+//! through generated lexicon types and resolved to Bluesky CDN URLs in appview
88+//! response fields; no fake CDN/media data is synthesized.
99//!
1010//! Cursor contract: clients treat `cursor` as opaque.
1111//! - Chronological feeds (`getAuthorThings`, `getFeed?algorithm=recent`) keyset
···2525use jacquard::oauth::client::OAuthSession;
2626use jacquard_common::deps::smol_str::SmolStr;
2727use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue};
2828-use jacquard_common::types::{
2929- blob::BlobRef,
3030- value::{Data, to_data},
3131-};
2828+use jacquard_common::types::{blob::BlobRef, value::Data};
3229use polymodel_api::space_polymodel::library::{
3330 Actor, FeedItem, FeedView, File, Image, ImageView, ModelView, PartView, ThingView,
3431 ThingViewBasic, ViewerState, model::Model, part::Part, thing::Thing,
···6764/// Build the required `record: Data` field from a stored `record_json` blob.
6865fn record_data(record_json: Option<&str>) -> AppResult<Data> {
6966 let raw = record_json.ok_or_else(|| internal("content row missing record_json"))?;
7070- let value: serde_json::Value =
7171- serde_json::from_str(raw).map_err(|e| internal(format!("record_json parse: {e}")))?;
7272- to_data(&value).map_err(|e| internal(format!("record encode: {e}")))
6767+ serde_json::from_str::<Data>(raw).map_err(|e| internal(format!("record_json parse: {e}")))
7368}
74697570fn file_data(file_json: Option<&str>) -> AppResult<File> {
···11541149 };
11551150 let stats = profile_stats(state, did.as_ref()).await?;
11561151 let handle_str = resolve_handle(state, did).await?;
11571157- let record_value: serde_json::Value = serde_json::from_str(&row.record_json)
11521152+ let record: Data = serde_json::from_str(&row.record_json)
11581153 .map_err(|e| internal(format!("profile record_json parse: {e}")))?;
11591154 Ok(Some(polymodel_api::space_polymodel::actor::ProfileView {
11601155 did: did.clone(),
11611156 handle: s(handle_str),
11621162- record: to_data(&record_value).map_err(|e| internal(format!("record encode: {e}")))?,
11571157+ record,
11631158 display_name: row.display_name.map(s),
11641159 description: row.description.map(s),
11651160 avatar: avatar_url(did.as_ref(), row.avatar_json.as_deref())?,
+2-2
src/appview/writes.rs
···2222use jacquard_common::types::ident::AtIdentifier;
2323use jacquard_common::types::recordkey::{RecordKey, Rkey};
2424use jacquard_common::types::string::{AtUri, Cid, Datetime, Did};
2525+use jacquard_common::types::value::to_data;
2526use polymodel_api::app_bsky::actor::profile::Profile as BskyProfile;
2627use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
2728use polymodel_api::space_polymodel::actor::ProfileView;
···12551256 record: &R,
12561257 action: &str,
12571258) -> AppResult<()> {
12581258- let record =
12591259- serde_json::to_value(record).map_err(|e| internal(format!("record serialize: {e}")))?;
12591259+ let record = to_data(record).map_err(|e| internal(format!("record serialize: {e}")))?;
12601260 let event = ProjectionEvent {
12611261 seq: 0,
12621262 did: actor.as_ref().to_string(),
+330-154
src/indexing/projection.rs
···1010//! subject of the existing row, delete it, and only decrement if a row was
1111//! actually removed.
12121313-use serde_json::Value;
1313+use jacquard_common::types::value::{Data, DataDeserializerError, from_data, to_data};
1414+use polymodel_api::space_polymodel::{
1515+ actor::profile::Profile,
1616+ graph::{like::Like, listitem::Listitem, save::Save, tag::Tag},
1717+ library::{model::Model, part::Part, thing::Thing},
1818+};
1419use sqlx::{Sqlite, SqlitePool, Transaction};
15201621/// A repo-record projection event, decoupled from Hydrant's internal types so
···2429 pub rkey: String,
2530 /// `"create"`, `"update"`, or `"delete"`.
2631 pub action: String,
2727- /// The record body for create/update events; `None` for delete events.
2828- pub record: Option<Value>,
3232+ /// The record body for create/update events, carried as the native AT
3333+ /// Protocol value type; `None` for delete events.
3434+ pub record: Option<Data>,
2935 pub cid: Option<String>,
3036}
3137···4450/// Account status events are not projected and are dropped by
4551/// [`ProjectionInput::from_hydrant`].
4652#[derive(Debug, Clone)]
5353+#[allow(clippy::large_enum_variant)]
4754pub enum ProjectionInput {
4855 Record(ProjectionEvent),
4956 Identity(IdentityProjection),
···6370 collection: rec.collection.to_string(),
6471 rkey: rec.rkey.to_string(),
6572 action: rec.action.to_string(),
6666- record: rec.record.clone(),
7373+ record: rec.record.as_ref().and_then(|v| {
7474+ to_data(v)
7575+ .map_err(|e| tracing::warn!(error = %e, "to_data conversion failed"))
7676+ .ok()
7777+ }),
6778 cid: rec.cid.as_ref().map(|c| c.to_string()),
6879 })));
6980 }
···143154 let (Some(cid), Some(record)) = (event.cid.clone(), event.record.as_ref()) else {
144155 return Ok(());
145156 };
146146- let value = record.to_string();
157157+ let value = serde_json::to_string(record).unwrap_or_default();
147158 let now = now_nanos();
148159 sqlx::query!(
149160 "INSERT INTO pending_writes (uri, cid, value, written_at) VALUES (?, ?, ?, ?) \
···282293}
283294284295// ---------------------------------------------------------------------------
285285-// Field extraction helpers (records arrive as serde_json::Value).
296296+// Typed record decode from borrowed Data.
286297// ---------------------------------------------------------------------------
287298288288-fn s_str(v: &Value, key: &str) -> Option<String> {
289289- v.get(key).and_then(|x| x.as_str()).map(String::from)
290290-}
291291-292292-/// URI from a single strongRef field, e.g. `derivedFrom: { uri, cid }`.
293293-fn ref_uri(v: &Value, key: &str) -> Option<String> {
294294- v.get(key)
295295- .and_then(|r| r.get("uri"))
296296- .and_then(|u| u.as_str())
297297- .map(String::from)
298298-}
299299-300300-/// URIs from an array of strongRefs, e.g. `models: [{ uri, cid }, ...]`.
301301-fn ref_uris(v: &Value, key: &str) -> Vec<String> {
302302- v.get(key)
303303- .and_then(|x| x.as_array())
304304- .map(|arr| {
305305- arr.iter()
306306- .filter_map(|el| el.get("uri").and_then(|u| u.as_str()).map(String::from))
307307- .collect()
308308- })
309309- .unwrap_or_default()
310310-}
311311-312312-fn str_array(v: &Value, key: &str) -> Vec<String> {
313313- v.get(key)
314314- .and_then(|x| x.as_array())
315315- .map(|arr| {
316316- arr.iter()
317317- .filter_map(|el| el.as_str().map(String::from))
318318- .collect()
319319- })
320320- .unwrap_or_default()
321321-}
322322-323323-fn parse_nanos(v: &Value, key: &str) -> Option<i64> {
324324- chrono::DateTime::parse_from_rfc3339(v.get(key)?.as_str()?)
325325- .ok()
326326- .and_then(|dt| dt.timestamp_nanos_opt())
299299+/// Decode a typed record from a borrowed `&Data` body, injecting the
300300+/// collection NSID as the `$type` tag that all polymodel record structs are
301301+/// internally tagged on. Wire bodies and test fixtures may omit `$type`;
302302+/// injecting it at this single decode chokepoint — through which every record
303303+/// flows (firehose, eager writes, proxy, fixtures) — lets `from_data` succeed
304304+/// uniformly. The tag is always inserted/replaced; this is safe because
305305+/// dispatch is collection-keyed, so the injected value always matches the
306306+/// target struct's `#[serde(rename)]`.
307307+fn decode<T>(data: &Data, collection: &str) -> Result<T, DataDeserializerError>
308308+where
309309+ T: for<'de> serde::Deserialize<'de>,
310310+{
311311+ let mut tagged = data.clone();
312312+ if let Some(obj) = tagged.as_object_mut() {
313313+ obj.0.insert(
314314+ "$type".into(),
315315+ to_data(&collection.to_string()).expect("serializing a string cannot fail"),
316316+ );
317317+ }
318318+ from_data(&tagged)
327319}
328320329321fn now_nanos() -> i64 {
···344336 tx: &mut Transaction<'_, Sqlite>,
345337 e: &ProjectionEvent,
346338) -> anyhow::Result<()> {
347347- let Some(rec) = &e.record else {
339339+ let Some(data) = &e.record else {
348340 return Ok(());
341341+ };
342342+ let rec = match decode::<Thing>(data, &e.collection) {
343343+ Ok(r) => r,
344344+ Err(err) => {
345345+ tracing::warn!(error = %err, "thing record decode failed");
346346+ return Ok(());
347347+ }
349348 };
350349 let uri = at_uri(&e.did, &e.collection, &e.rkey);
351351- // Required content fields: skip the event gracefully if absent (malformed
352352- // record) rather than storing a blank row.
353350 let Some(cid) = e.cid.clone() else {
354351 return Ok(());
355352 };
356356- let Some(name) = s_str(rec, "name") else {
357357- return Ok(());
358358- };
359359- let summary = s_str(rec, "summary");
360360- let license = s_str(rec, "license");
361361- let instructions = s_str(rec, "instructions");
362362- let cover_json = rec.get("cover").map(|v| v.to_string());
363363- let derived_from_uri = ref_uri(rec, "derivedFrom");
353353+ let name = rec.name.to_string();
354354+ let summary = rec.summary.as_ref().map(|s| s.to_string());
355355+ let license = rec.license.to_string();
356356+ let instructions = rec.instructions.as_ref().map(|lines| {
357357+ lines
358358+ .iter()
359359+ .map(|s| s.to_string())
360360+ .collect::<Vec<_>>()
361361+ .join("\n")
362362+ });
363363+ let cover_json = data
364364+ .as_object()
365365+ .and_then(|o| o.0.get("cover"))
366366+ .and_then(|v| serde_json::to_string(v).ok());
367367+ let derived_from_uri = rec
368368+ .derived_from
369369+ .as_ref()
370370+ .map(|r| r.uri.as_str().to_string());
364371365365- let tags = str_array(rec, "tags");
372372+ let tags = rec
373373+ .tags
374374+ .as_ref()
375375+ .map(|v| v.iter().map(|s| s.to_string()).collect::<Vec<_>>())
376376+ .unwrap_or_default();
366377 let tags_json = if tags.is_empty() {
367378 None
368379 } else {
···374385 Some(tags.join(" "))
375386 };
376387377377- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
388388+ let created_at = rec
389389+ .created_at
390390+ .as_ref()
391391+ .timestamp_nanos_opt()
392392+ .unwrap_or_else(now_nanos);
378393 let indexed_at = now_nanos();
379379- // The full raw record, so read endpoints can populate the required `record`
380380- // field on thingView directly from SQLite (and the write path gets
381381- // read-your-own-write, since Hydrant has no public single-record write API).
382382- let record_json = rec.to_string();
394394+ let record_json = serde_json::to_string(data).unwrap_or_default();
383395384396 // UPSERT (not INSERT OR REPLACE): REPLACE performs DELETE+INSERT with a new
385397 // rowid, which skips the AFTER UPDATE FTS trigger and (with recursive_triggers
···421433 sqlx::query!("DELETE FROM thing_models WHERE thing_uri = ?", uri)
422434 .execute(&mut **tx)
423435 .await?;
424424- for (pos, model_uri) in ref_uris(rec, "models").into_iter().enumerate() {
436436+ for (pos, model_ref) in rec.models.iter().flatten().enumerate() {
425437 sqlx::query!(
426438 "INSERT OR REPLACE INTO thing_models (thing_uri, model_uri, position) \
427439 VALUES (?, ?, ?)",
428440 uri,
429429- model_uri,
441441+ model_ref.uri.as_str(),
430442 pos as i64,
431443 )
432444 .execute(&mut **tx)
···470482 tx: &mut Transaction<'_, Sqlite>,
471483 e: &ProjectionEvent,
472484) -> anyhow::Result<()> {
473473- let Some(rec) = &e.record else {
485485+ let Some(data) = &e.record else {
474486 return Ok(());
475487 };
488488+ let rec = match decode::<Model>(data, &e.collection) {
489489+ Ok(r) => r,
490490+ Err(err) => {
491491+ tracing::warn!(error = %err, "model record decode failed");
492492+ return Ok(());
493493+ }
494494+ };
476495 let uri = at_uri(&e.did, &e.collection, &e.rkey);
477496 let Some(cid) = e.cid.clone() else {
478497 return Ok(());
479498 };
480480- let Some(name) = s_str(rec, "name") else {
481481- return Ok(());
482482- };
483483- let summary = s_str(rec, "summary");
484484- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
499499+ let name = rec.name.to_string();
500500+ let summary = rec.summary.as_ref().map(|s| s.to_string());
501501+ let created_at = rec
502502+ .created_at
503503+ .as_ref()
504504+ .timestamp_nanos_opt()
505505+ .unwrap_or_else(now_nanos);
485506 let indexed_at = now_nanos();
486486- let record_json = rec.to_string();
507507+ let record_json = serde_json::to_string(data).unwrap_or_default();
487508488509 sqlx::query!(
489510 "INSERT INTO models \
···509530 sqlx::query!("DELETE FROM model_parts WHERE model_uri = ?", uri)
510531 .execute(&mut **tx)
511532 .await?;
512512- for (pos, part_uri) in ref_uris(rec, "parts").into_iter().enumerate() {
533533+ for (pos, part_ref) in rec.parts.iter().enumerate() {
513534 sqlx::query!(
514535 "INSERT OR REPLACE INTO model_parts (model_uri, part_uri, position) \
515536 VALUES (?, ?, ?)",
516537 uri,
517517- part_uri,
538538+ part_ref.uri.as_str(),
518539 pos as i64,
519540 )
520541 .execute(&mut **tx)
···545566 tx: &mut Transaction<'_, Sqlite>,
546567 e: &ProjectionEvent,
547568) -> anyhow::Result<()> {
548548- let Some(rec) = &e.record else {
569569+ let Some(data) = &e.record else {
549570 return Ok(());
550571 };
572572+ let rec = match decode::<Part>(data, &e.collection) {
573573+ Ok(r) => r,
574574+ Err(err) => {
575575+ tracing::warn!(error = %err, "part record decode failed");
576576+ return Ok(());
577577+ }
578578+ };
551579 let uri = at_uri(&e.did, &e.collection, &e.rkey);
552580 let Some(cid) = e.cid.clone() else {
553581 return Ok(());
554582 };
555555- let Some(name) = s_str(rec, "name") else {
556556- return Ok(());
557557- };
558558- let format = s_str(rec, "format");
559559- let file_json = rec.get("file").map(|v| v.to_string());
560560- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
583583+ let name = rec.name.to_string();
584584+ let format = rec.format.as_ref().map(|s| s.to_string());
585585+ let file_json = data
586586+ .as_object()
587587+ .and_then(|o| o.0.get("file"))
588588+ .and_then(|v| serde_json::to_string(v).ok());
589589+ let created_at = rec
590590+ .created_at
591591+ .as_ref()
592592+ .timestamp_nanos_opt()
593593+ .unwrap_or_else(now_nanos);
561594 let indexed_at = now_nanos();
562562- let record_json = rec.to_string();
595595+ let record_json = serde_json::to_string(data).unwrap_or_default();
563596564597 sqlx::query!(
565598 "INSERT INTO parts \
···613646 tx: &mut Transaction<'_, Sqlite>,
614647 e: &ProjectionEvent,
615648) -> anyhow::Result<()> {
616616- let Some(rec) = &e.record else {
649649+ let Some(data) = &e.record else {
617650 return Ok(());
618651 };
619619- let display_name = s_str(rec, "displayName");
620620- let description = s_str(rec, "description");
621621- let avatar_json = rec.get("avatar").map(|v| v.to_string());
622622- let default_license = s_str(rec, "defaultLicense");
623623- let pronouns = s_str(rec, "pronouns");
624624- let printers_json = rec
625625- .get("printers")
626626- .and_then(|v| v.as_array())
627627- .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
628628- let links_json = rec
629629- .get("links")
630630- .and_then(|v| v.as_array())
631631- .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
632632- let record_json = rec.to_string();
652652+ let rec = match decode::<Profile>(data, &e.collection) {
653653+ Ok(r) => r,
654654+ Err(err) => {
655655+ tracing::warn!(error = %err, "profile record decode failed");
656656+ return Ok(());
657657+ }
658658+ };
659659+ let display_name = rec.display_name.as_ref().map(|s| s.to_string());
660660+ let description = rec.description.as_ref().map(|s| s.to_string());
661661+ let avatar_json = data
662662+ .as_object()
663663+ .and_then(|o| o.0.get("avatar"))
664664+ .and_then(|v| serde_json::to_string(v).ok());
665665+ let default_license = rec.default_license.as_ref().map(|s| s.to_string());
666666+ let pronouns = rec.pronouns.as_ref().map(|s| s.to_string());
667667+ let printers_json = data
668668+ .as_object()
669669+ .and_then(|o| o.0.get("printers"))
670670+ .and_then(|v| serde_json::to_string(v).ok());
671671+ let links_json = data
672672+ .as_object()
673673+ .and_then(|o| o.0.get("links"))
674674+ .and_then(|v| serde_json::to_string(v).ok());
675675+ let record_json = serde_json::to_string(data).unwrap_or_default();
633676 let indexed_at = now_nanos();
634677635678 sqlx::query!(
···703746 tx: &mut Transaction<'_, Sqlite>,
704747 e: &ProjectionEvent,
705748) -> anyhow::Result<()> {
706706- let Some(rec) = &e.record else {
749749+ let Some(data) = &e.record else {
707750 return Ok(());
708751 };
709709- let Some(subject) = ref_uri(rec, "subject") else {
710710- return Ok(());
752752+ let rec = match decode::<Like>(data, &e.collection) {
753753+ Ok(r) => r,
754754+ Err(err) => {
755755+ tracing::warn!(error = %err, "like record decode failed");
756756+ return Ok(());
757757+ }
711758 };
759759+ let subject = rec.subject.uri.as_str().to_string();
712760 let Some(cid) = e.cid.clone() else {
713761 return Ok(());
714762 };
715715- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
763763+ let created_at = rec
764764+ .created_at
765765+ .as_ref()
766766+ .timestamp_nanos_opt()
767767+ .unwrap_or_else(now_nanos);
716768717769 let res = sqlx::query!(
718770 "INSERT OR IGNORE INTO likes (did, rkey, cid, subject_uri, created_at) VALUES (?, ?, ?, ?, ?)",
···775827 tx: &mut Transaction<'_, Sqlite>,
776828 e: &ProjectionEvent,
777829) -> anyhow::Result<()> {
778778- let Some(rec) = &e.record else {
830830+ let Some(data) = &e.record else {
779831 return Ok(());
780832 };
781781- let Some(subject) = ref_uri(rec, "subject") else {
782782- return Ok(());
833833+ let rec = match decode::<Save>(data, &e.collection) {
834834+ Ok(r) => r,
835835+ Err(err) => {
836836+ tracing::warn!(error = %err, "save record decode failed");
837837+ return Ok(());
838838+ }
783839 };
840840+ let subject = rec.subject.uri.as_str().to_string();
784841 let Some(cid) = e.cid.clone() else {
785842 return Ok(());
786843 };
787787- let note = s_str(rec, "note");
788788- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
844844+ let note = rec.note.as_ref().map(|s| s.to_string());
845845+ let created_at = rec
846846+ .created_at
847847+ .as_ref()
848848+ .timestamp_nanos_opt()
849849+ .unwrap_or_else(now_nanos);
789850790851 let res = sqlx::query!(
791852 "INSERT OR IGNORE INTO saves (did, rkey, cid, subject_uri, note, created_at) \
···850911 tx: &mut Transaction<'_, Sqlite>,
851912 e: &ProjectionEvent,
852913) -> anyhow::Result<()> {
853853- let Some(rec) = &e.record else {
914914+ let Some(data) = &e.record else {
854915 return Ok(());
855916 };
856856- let Some(subject) = ref_uri(rec, "subject") else {
857857- return Ok(());
917917+ let rec = match decode::<Tag>(data, &e.collection) {
918918+ Ok(r) => r,
919919+ Err(err) => {
920920+ tracing::warn!(error = %err, "tag record decode failed");
921921+ return Ok(());
922922+ }
858923 };
859859- let Some(tag) = s_str(rec, "tag") else {
860860- return Ok(());
861861- };
924924+ let subject = rec.subject.uri.as_str().to_string();
925925+ let tag = rec.tag.to_string();
862926 let Some(cid) = e.cid.clone() else {
863927 return Ok(());
864928 };
865865- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
929929+ let created_at = rec
930930+ .created_at
931931+ .as_ref()
932932+ .timestamp_nanos_opt()
933933+ .unwrap_or_else(now_nanos);
866934867935 let res = sqlx::query!(
868936 "INSERT OR IGNORE INTO tags (did, rkey, cid, subject_uri, tag, created_at) \
···923991 tx: &mut Transaction<'_, Sqlite>,
924992 e: &ProjectionEvent,
925993) -> anyhow::Result<()> {
926926- let Some(rec) = &e.record else {
994994+ let Some(data) = &e.record else {
927995 return Ok(());
928996 };
929929- let Some(list_uri) = ref_uri(rec, "list") else {
930930- return Ok(());
997997+ let rec = match decode::<Listitem>(data, &e.collection) {
998998+ Ok(r) => r,
999999+ Err(err) => {
10001000+ tracing::warn!(error = %err, "listitem record decode failed");
10011001+ return Ok(());
10021002+ }
9311003 };
932932- let Some(subject_uri) = ref_uri(rec, "subject") else {
933933- return Ok(());
934934- };
10041004+ let list_uri = rec.list.as_str().to_string();
10051005+ let subject_uri = rec.subject.uri.as_str().to_string();
9351006 let Some(cid) = e.cid.clone() else {
9361007 return Ok(());
9371008 };
938938- let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos);
10091009+ let created_at = rec
10101010+ .created_at
10111011+ .as_ref()
10121012+ .timestamp_nanos_opt()
10131013+ .unwrap_or_else(now_nanos);
93910149401015 // No denormalized count for listitems; list membership is queried directly.
9411016 sqlx::query!(
···10101085 collection: "space.polymodel.library.thing".to_string(),
10111086 rkey: rkey.to_string(),
10121087 action: "create".to_string(),
10131013- record: Some(json!({
10881088+ record: Some(to_data(&json!({
10141089 "name": name,
10151090 "summary": "a thing summary",
10161091 "license": "MIT", "tags": ["cube", "calibration"],
10171092 "createdAt": "2024-01-15T12:00:00.000Z",
10181018- "models": models.iter().map(|m| json!({"uri": m, "cid": "c"})).collect::<Vec<_>>(),
10191019- })),
10931093+ "models": models.iter().map(|m| json!({"uri": format!("at://{did}/space.polymodel.library.model/{m}"), "cid": "c"})).collect::<Vec<_>>(),
10941094+ })).unwrap()),
10201095 cid: Some("thingcid".to_string()),
10211096 }
10221097 }
···10281103 collection: "space.polymodel.graph.like".to_string(),
10291104 rkey: rkey.to_string(),
10301105 action: "create".to_string(),
10311031- record: Some(json!({
10321032- "subject": {"uri": subject, "cid": "c"},
10331033- "createdAt": "2024-02-01T00:00:00.000Z",
10341034- })),
11061106+ record: Some(
11071107+ to_data(&json!({
11081108+ "subject": {"uri": subject, "cid": "c"},
11091109+ "createdAt": "2024-02-01T00:00:00.000Z",
11101110+ }))
11111111+ .unwrap(),
11121112+ ),
10351113 cid: Some("likecid".to_string()),
10361114 }
10371115 }
···11251203 let db = test_db().await;
11261204 let did = "did:plc:abc";
11271205 let uri = "at://did:plc:abc/space.polymodel.library.thing/r1";
11281128- let e = thing_event(10, did, "r1", "Calibration Cube", &["at://m1", "at://m2"]);
12061206+ let e = thing_event(10, did, "r1", "Calibration Cube", &["m1", "m2"]);
11291207 project_event(&db, &ProjectionInput::Record(e))
11301208 .await
11311209 .expect("project thing");
···11501228 // Create with two models.
11511229 project_event(
11521230 &db,
11531153- &ProjectionInput::Record(thing_event(1, did, "r1", "Cube", &["at://m1", "at://m2"])),
12311231+ &ProjectionInput::Record(thing_event(1, did, "r1", "Cube", &["m1", "m2"])),
11541232 )
11551233 .await
11561234 .unwrap();
11571235 assert_eq!(junction_count(&db, uri).await, 2);
1158123611591237 // Update: same rkey, different models. Old junction rows must be replaced.
11601160- let mut upd = thing_event(2, did, "r1", "Cube v2", &["at://m3"]);
12381238+ let mut upd = thing_event(2, did, "r1", "Cube v2", &["m3"]);
11611239 upd.action = "update".to_string();
11621240 project_event(&db, &ProjectionInput::Record(upd))
11631241 .await
···11661244 assert_eq!(junction_count(&db, uri).await, 1);
11671245 // m1/m2 gone, m3 present.
11681246 let has_m3 = sqlx::query_as::<_, CountRow>(
11691169- "SELECT COUNT(*) AS count FROM thing_models WHERE thing_uri = ? AND model_uri = 'at://m3'",
12471247+ "SELECT COUNT(*) AS count FROM thing_models WHERE thing_uri = ? AND model_uri = 'at://did:plc:abc/space.polymodel.library.model/m3'",
11701248 )
11711249 .bind(uri)
11721250 .fetch_one(&db)
···11831261 let uri = "at://did:plc:abc/space.polymodel.library.thing/r1";
11841262 project_event(
11851263 &db,
11861186- &ProjectionInput::Record(thing_event(1, did, "r1", "Cube", &["at://m1"])),
12641264+ &ProjectionInput::Record(thing_event(1, did, "r1", "Cube", &["m1"])),
11871265 )
11881266 .await
11891267 .unwrap();
···12041282 #[tokio::test]
12051283 async fn like_create_increments_count() {
12061284 let db = test_db().await;
12071207- let subject = "at://did:plc:author/.../thing";
12851285+ let subject = "at://did:plc:author/space.polymodel.library.thing/t1";
12081286 project_event(
12091287 &db,
12101288 &ProjectionInput::Record(like_event(1, "did:plc:a", "k1", subject)),
···12251303 #[tokio::test]
12261304 async fn like_delete_decrements_count_two_step() {
12271305 let db = test_db().await;
12281228- let subject = "at://did:plc:author/.../thing";
13061306+ let subject = "at://did:plc:author/space.polymodel.library.thing/t1";
12291307 project_event(
12301308 &db,
12311309 &ProjectionInput::Record(like_event(1, "did:plc:a", "k1", subject)),
···12521330 async fn like_delete_when_absent_is_idempotent() {
12531331 // Deleting a like that was never there must not panic nor go negative.
12541332 let db = test_db().await;
12551255- let subject = "at://did:plc:author/.../thing";
13331333+ let subject = "at://did:plc:author/space.polymodel.library.thing/t1";
12561334 project_event(
12571335 &db,
12581336 &ProjectionInput::Record(delete_event(
···12711349 async fn duplicate_like_does_not_double_count() {
12721350 // Replay: same like event twice (e.g. eager write + firehose delivery).
12731351 let db = test_db().await;
12741274- let subject = "at://did:plc:author/.../thing";
13521352+ let subject = "at://did:plc:author/space.polymodel.library.thing/t1";
12751353 project_event(
12761354 &db,
12771355 &ProjectionInput::Record(like_event(1, "did:plc:a", "k1", subject)),
···13011379 assert_eq!(cursor(&db).await, Some(7));
13021380 project_event(
13031381 &db,
13041304- &ProjectionInput::Record(like_event(99, "did:plc:b", "k1", "at://x")),
13821382+ &ProjectionInput::Record(like_event(
13831383+ 99,
13841384+ "did:plc:b",
13851385+ "k1",
13861386+ "at://did:plc:author/space.polymodel.library.thing/x",
13871387+ )),
13051388 )
13061389 .await
13071390 .unwrap();
···13111394 #[tokio::test]
13121395 async fn save_create_and_delete_maintain_count() {
13131396 let db = test_db().await;
13141314- let subject = "at://did:plc:author/.../thing";
13971397+ let subject = "at://did:plc:author/space.polymodel.library.thing/t1";
13151398 let save = ProjectionEvent {
13161399 seq: 1,
13171400 did: "did:plc:a".to_string(),
13181401 collection: "space.polymodel.graph.save".to_string(),
13191402 rkey: "s1".to_string(),
13201403 action: "create".to_string(),
13211321- record: Some(json!({
13221322- "subject": {"uri": subject, "cid": "c"},
13231323- "note": "useful",
13241324- "createdAt": "2024-03-01T00:00:00.000Z",
13251325- })),
14041404+ record: Some(
14051405+ to_data(&json!({
14061406+ "subject": {"uri": subject, "cid": "c"},
14071407+ "note": "useful",
14081408+ "createdAt": "2024-03-01T00:00:00.000Z",
14091409+ }))
14101410+ .unwrap(),
14111411+ ),
13261412 cid: Some("savecid".to_string()),
13271413 };
13281414 project_event(&db, &ProjectionInput::Record(save))
···13611447 collection: "space.polymodel.library.thing".to_string(),
13621448 rkey: "r1".to_string(),
13631449 action: "create".to_string(),
13641364- record: Some(json!({
13651365- "name": "Calibration Cube",
13661366- "license": "MIT", "tags": ["cube"],
13671367- "createdAt": "2024-01-01T00:00:00.000Z",
13681368- "models": [],
13691369- })),
14501450+ record: Some(
14511451+ to_data(&json!({
14521452+ "name": "Calibration Cube",
14531453+ "license": "MIT", "tags": ["cube"],
14541454+ "createdAt": "2024-01-01T00:00:00.000Z",
14551455+ "models": [],
14561456+ }))
14571457+ .unwrap(),
14581458+ ),
13701459 cid: Some("cid1".to_string()),
13711460 };
13721461 project_event(&db, &ProjectionInput::Record(create.clone()))
···13781467 update.seq = 2;
13791468 update.action = "update".to_string();
13801469 update.cid = Some("cid2".to_string());
13811381- update.record = Some(json!({
13821382- "name": "Friendly Sphere",
13831383- "license": "MIT", "tags": ["sphere"],
13841384- "createdAt": "2024-01-02T00:00:00.000Z",
13851385- "models": [],
13861386- }));
14701470+ update.record = Some(
14711471+ to_data(&json!({
14721472+ "name": "Friendly Sphere",
14731473+ "license": "MIT", "tags": ["sphere"],
14741474+ "createdAt": "2024-01-02T00:00:00.000Z",
14751475+ "models": [],
14761476+ }))
14771477+ .unwrap(),
14781478+ );
13871479 project_event(&db, &ProjectionInput::Record(update))
13881480 .await
13891481 .unwrap();
···14041496 .unwrap()
14051497 .count;
14061498 assert_eq!(live, 1);
14991499+ }
15001500+15011501+ #[tokio::test]
15021502+ async fn malformed_and_incomplete_records_skip_gracefully() {
15031503+ // A non-object body and a lexicon-incomplete record (Thing missing the
15041504+ // required `createdAt`) must both degrade to a skip: project_event
15051505+ // returns Ok, the cursor advances, and no partial row is written.
15061506+ let db = test_db().await;
15071507+ let did = "did:plc:abc";
15081508+15091509+ let poison = ProjectionEvent {
15101510+ seq: 1,
15111511+ did: did.to_string(),
15121512+ collection: "space.polymodel.library.thing".to_string(),
15131513+ rkey: "bad1".to_string(),
15141514+ action: "create".to_string(),
15151515+ record: Some(to_data(&json!("not an object")).unwrap()),
15161516+ cid: Some("cid".to_string()),
15171517+ };
15181518+ project_event(&db, &ProjectionInput::Record(poison))
15191519+ .await
15201520+ .expect("malformed record must not poison the stream");
15211521+ assert_eq!(cursor(&db).await, Some(1));
15221522+15231523+ let incomplete = ProjectionEvent {
15241524+ seq: 2,
15251525+ did: did.to_string(),
15261526+ collection: "space.polymodel.library.thing".to_string(),
15271527+ rkey: "bad2".to_string(),
15281528+ action: "create".to_string(),
15291529+ record: Some(to_data(&json!({"name": "Incomplete", "license": "MIT"})).unwrap()),
15301530+ cid: Some("cid".to_string()),
15311531+ };
15321532+ project_event(&db, &ProjectionInput::Record(incomplete))
15331533+ .await
15341534+ .expect("incomplete record must not poison the stream");
15351535+ assert_eq!(cursor(&db).await, Some(2));
15361536+15371537+ let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things")
15381538+ .fetch_one(&db)
15391539+ .await
15401540+ .unwrap();
15411541+ assert_eq!(rows, 0, "no partial rows should exist for skipped records");
15421542+ }
15431543+15441544+ #[tokio::test]
15451545+ async fn listitem_create_projects_with_direct_list_uri() {
15461546+ // Regression: the old ref_uri("list") expected a nested {uri, cid}
15471547+ // object, but the lexicon type is a direct AtUri. Conformant listitems
15481548+ // were silently never projected. The typed path now projects them.
15491549+ let db = test_db().await;
15501550+ let list_uri = "at://did:plc:abc/space.polymodel.graph.list/mylist";
15511551+ let subject_uri = "at://did:plc:xyz/space.polymodel.library.thing/t1";
15521552+ let event = ProjectionEvent {
15531553+ seq: 10,
15541554+ did: "did:plc:a".to_string(),
15551555+ collection: "space.polymodel.graph.listitem".to_string(),
15561556+ rkey: "li1".to_string(),
15571557+ action: "create".to_string(),
15581558+ record: Some(
15591559+ to_data(&json!({
15601560+ "list": list_uri,
15611561+ "subject": {"uri": subject_uri, "cid": "c"},
15621562+ "createdAt": "2024-04-01T00:00:00.000Z",
15631563+ }))
15641564+ .unwrap(),
15651565+ ),
15661566+ cid: Some("licid".to_string()),
15671567+ };
15681568+ project_event(&db, &ProjectionInput::Record(event))
15691569+ .await
15701570+ .unwrap();
15711571+15721572+ let row: Option<(String, String)> = sqlx::query_as(
15731573+ "SELECT list_uri, subject_uri FROM listitems WHERE did = ? AND rkey = ?",
15741574+ )
15751575+ .bind("did:plc:a")
15761576+ .bind("li1")
15771577+ .fetch_optional(&db)
15781578+ .await
15791579+ .unwrap();
15801580+ let (stored_list, stored_subject) = row.expect("listitem must be projected");
15811581+ assert_eq!(stored_list, list_uri);
15821582+ assert_eq!(stored_subject, subject_uri);
14071583 }
14081584}