diff --git a/packages/docs/content/docs/api-reference/lua/database-api.md b/packages/docs/content/docs/api-reference/lua/database-api.md index cc6a9ce..434a019 100644 --- a/packages/docs/content/docs/api-reference/lua/database-api.md +++ b/packages/docs/content/docs/api-reference/lua/database-api.md @@ -25,6 +25,10 @@ The `cursor` is an opaque string returned in a previous response. Pass it throug The `sort` field can be a top-level column (`indexed_at`, `did`, `uri`) or any field inside the record (e.g. `name`, `createdAt`). Nested paths are supported with dot notation and array indices (e.g. `author.handle`, `scores[0]`). + +`indexed_at` records when a record arrived **from the network**, not when the AppView last wrote it. A record this instance just created is NULL there until Jetstream echoes it back, so sorting or filtering on `indexed_at` puts brand-new records in an unintuitive spot for that window. Sort on `created_at` (the default when no `sort` is given) or on a record field like `createdAt` when you want write order. + + ### Filtering The `filter` option lets you restrict results by record field values. Field names correspond to the fields defined in your lexicon schema (e.g. `streamer`, `status`, `viewers`). diff --git a/packages/docs/content/docs/api-reference/lua/record-api.md b/packages/docs/content/docs/api-reference/lua/record-api.md index 419b0bc..e018406 100644 --- a/packages/docs/content/docs/api-reference/lua/record-api.md +++ b/packages/docs/content/docs/api-reference/lua/record-api.md @@ -59,6 +59,14 @@ r:set_rkey("my-key") local key = r:generate_rkey() ``` +### `save_local` and network provenance + +`cid` and `indexed_at` describe the record as it exists **on the network**, so `save_local()` never writes them. + +On an existing record — the redaction flow this method exists for — both are left exactly as they were. The CID still describes what the PDS holds, since editing the local copy doesn't change the repo's copy, and it's what `_cid` feeds into strongRefs. `indexed_at` still records when the firehose delivered it. + +On a record that has never existed on a PDS, there is nothing to describe: `cid` is empty and `indexed_at` is NULL. A row with `indexed_at IS NULL` is precisely "written locally, not yet echoed back" — use `COALESCE(indexed_at, created_at)` when you need a timestamp regardless. + **Key type behavior for `generate_rkey()`:** | Key type | Generated rkey | diff --git a/src/db.rs b/src/db.rs index 23778d7..ee79fac 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,6 +12,21 @@ use sqlx::{query as sqlx_query, query_as as sqlx_query_as, query_scalar as sqlx_ use std::path::Path; use std::sync::LazyLock; +/// Bind value for `happyview_records.indexed_at` on **local** write paths. +/// +/// `indexed_at` records when a record arrived *from the network*, so only the +/// Jetstream consumer (`record_handler`) and backfill may write it. Everything +/// else — `Record:save()`, `save_local()`, the built-in XRPC procedure +/// handlers, linked-repo writes — binds this on insert and omits the column +/// from its `ON CONFLICT` branch, so a real arrival time survives an +/// AppView-side edit. +/// +/// It has to be an explicit NULL rather than an omitted column: SQLite still +/// declares `indexed_at TEXT DEFAULT (datetime('now'))` while Postgres dropped +/// its default in `20260213000000_add_created_at.sql`, so leaving the column out +/// would fabricate an arrival time on one backend and not the other. +pub const NO_INDEXED_AT: Option<&str> = None; + // --------------------------------------------------------------------------- // SQL query helpers (sqlx 0.9 `SqlSafeStr`) // --------------------------------------------------------------------------- diff --git a/src/linked_repos/pds.rs b/src/linked_repos/pds.rs index 142c631..11b7c03 100644 --- a/src/linked_repos/pds.rs +++ b/src/linked_repos/pds.rs @@ -138,6 +138,71 @@ async fn post_json( } } +/// Mirror a successful linked-repo write into the local record index. +/// +/// This matches what `Record:save()` does after its own PDS write +/// (`lua/record.rs`): a best-effort upsert plus a `sync_refs` pass, with no +/// filter on whether the collection is a tracked record lexicon. Failures are +/// swallowed deliberately — the PDS write has already landed, so the caller's +/// operation succeeded whether or not we managed to index it, and the record +/// will be indexed anyway when Jetstream echoes it back. +/// +/// `indexed_at` is left alone: it is network-arrival provenance, and this +/// record has not come back over the firehose yet. See [`crate::db::NO_INDEXED_AT`]. +/// +/// Public so tests can exercise the statement directly. They need to: the +/// errors are swallowed here, so a column/bind mismatch would be invisible in +/// production rather than loud. +pub async fn index_write( + state: &AppState, + did: &str, + collection: &str, + uri: &str, + cid: &str, + record: &Value, +) { + let backend = state.db_backend; + let rkey = uri.split('/').next_back().unwrap_or_default(); + let record_str = serde_json::to_string(record).unwrap_or_default(); + let now = crate::db::now_rfc3339(); + + let sql = crate::db::adapt_sql( + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (uri) DO UPDATE + SET record = EXCLUDED.record, + cid = EXCLUDED.cid"#, + backend, + ); + let _ = crate::db::query(&sql) + .bind(uri) + .bind(did) + .bind(collection) + .bind(rkey) + .bind(&record_str) + .bind(cid) + .bind(crate::db::NO_INDEXED_AT) + .bind(&now) + .execute(&state.db) + .await; + + let _ = crate::record_refs::sync_refs(&state.db, uri, collection, record, backend).await; +} + +/// Remove a record from the local index after a successful linked-repo delete. +/// +/// Unlike `Record:delete()`, which drops the local row even when the PDS call +/// fails, this only runs once the PDS has actually accepted the delete — +/// `delete_record` propagates PDS errors to its caller rather than logging and +/// continuing. +pub async fn unindex_write(state: &AppState, uri: &str) { + let sql = crate::db::adapt_sql( + "DELETE FROM happyview_records WHERE uri = ?", + state.db_backend, + ); + let _ = crate::db::query(&sql).bind(uri).execute(&state.db).await; +} + fn extract_uri_cid(value: &Value, nsid: &str) -> Result<(String, String), AppError> { let uri = value .get("uri") @@ -171,7 +236,9 @@ pub async fn create_record( } let out = post_json(&session, "com.atproto.repo.createRecord", &body).await?; - extract_uri_cid(&out, "createRecord") + let (uri, cid) = extract_uri_cid(&out, "createRecord")?; + index_write(state, did, collection, &uri, &cid, &body["record"]).await; + Ok((uri, cid)) } pub async fn put_record( @@ -197,7 +264,9 @@ pub async fn put_record( } let out = post_json(&session, "com.atproto.repo.putRecord", &body).await?; - extract_uri_cid(&out, "putRecord") + let (uri, cid) = extract_uri_cid(&out, "putRecord")?; + index_write(state, did, collection, &uri, &cid, &body["record"]).await; + Ok((uri, cid)) } pub async fn delete_record( @@ -217,6 +286,7 @@ pub async fn delete_record( }); post_json(&session, "com.atproto.repo.deleteRecord", &body).await?; + unindex_write(state, &format!("at://{did}/{collection}/{rkey}")).await; Ok(()) } diff --git a/src/lua/record.rs b/src/lua/record.rs index a58a983..9b84ee0 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -152,8 +152,7 @@ pub(crate) fn register_record_api( VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, - cid = EXCLUDED.cid, - indexed_at = ?"#, + cid = EXCLUDED.cid"#, backend, ); let _ = crate::db::query(&upsert_sql) @@ -163,8 +162,7 @@ pub(crate) fn register_record_api( .bind(&rkey) .bind(&data_str) .bind(cid) - .bind(&now) - .bind(&now) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; @@ -212,8 +210,8 @@ pub(crate) fn register_record_api( let data_str = serde_json::to_string(&data).unwrap_or_default(); let now = now_rfc3339(); let upsert_sql = adapt_sql( - r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid"#, @@ -226,6 +224,7 @@ pub(crate) fn register_record_api( .bind(rkey) .bind(&data_str) .bind(cid) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; @@ -341,6 +340,9 @@ pub(crate) fn register_record_api( // `_repo_override` / `claims.did()` for the repo, and generate an // rkey via `_key_type` if `_rkey` isn't set. Errors clearly when no // DID can be determined. + // + // `cid` and `indexed_at` are network-derived and are never written here — + // see the comment on the upsert below. { let state = state.clone(); let claims = claims.clone(); @@ -417,15 +419,25 @@ pub(crate) fn register_record_api( (uri, repo, rkey) }; - // NULL CID — no PDS round-trip means we have no real CID - // to record. + // `save_local` never touches a PDS, so it has no CID of its own. + // + // On update it leaves the stored one alone. The CID describes + // the version the PDS holds, and a local-only edit — the + // redaction flow this method exists for — doesn't change that. + // It's also what `_cid` feeds into strongRefs, which must point + // at what's actually in the repo. Overwriting it here used to + // destroy that, exactly the way overwriting `indexed_at` + // destroyed network-arrival provenance. + // + // On insert there has never been a PDS version to describe, so + // the CID is empty rather than NULL: the column is NOT NULL on + // both backends, and `cid_verify` already reads an empty CID as + // "nothing to check" (`CidCheck::Skipped`). let upsert_sql = adapt_sql( r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE - SET record = EXCLUDED.record, - cid = EXCLUDED.cid, - indexed_at = ?"#, + SET record = EXCLUDED.record"#, backend, ); crate::db::query(&upsert_sql) @@ -434,9 +446,8 @@ pub(crate) fn register_record_api( .bind(&collection) .bind(&rkey) .bind(&data_str) - .bind(Option::::None) - .bind(&now) - .bind(&now) + .bind("") + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await @@ -445,7 +456,6 @@ pub(crate) fn register_record_api( let _ = sync_refs(&state.db, &uri, &collection, &data, backend).await; this.raw_set("_uri", uri.as_str())?; - this.raw_set("_cid", mlua::Value::Nil)?; Ok(this) } @@ -756,8 +766,7 @@ pub(crate) fn register_record_api( VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, - cid = EXCLUDED.cid, - indexed_at = ?"#, + cid = EXCLUDED.cid"#, backend, ); let _ = crate::db::query(&upsert_sql) @@ -767,8 +776,7 @@ pub(crate) fn register_record_api( .bind(&rkey) .bind(&data_str) .bind(cid) - .bind(&now) - .bind(&now) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; @@ -821,8 +829,8 @@ pub(crate) fn register_record_api( let data_str = serde_json::to_string(&data).unwrap_or_default(); let now = now_rfc3339(); let upsert_sql = adapt_sql( - r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + r#"INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid"#, @@ -835,6 +843,7 @@ pub(crate) fn register_record_api( .bind(rkey) .bind(&data_str) .bind(cid) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index c9c6c86..e665dcc 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -269,8 +269,8 @@ async fn handle_create_record( let record_str = serde_json::to_string(&record).unwrap_or_default(); let sql = adapt_sql( r#" - INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid @@ -285,6 +285,7 @@ async fn handle_create_record( .bind(rkey) .bind(&record_str) .bind(cid) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; @@ -356,12 +357,11 @@ async fn handle_put_record( let now = now_rfc3339(); let sql = adapt_sql( r#" - INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, - cid = EXCLUDED.cid, - indexed_at = ? + cid = EXCLUDED.cid "#, backend, ); @@ -372,7 +372,7 @@ async fn handle_put_record( .bind(&rkey) .bind(&record_str) .bind(cid) - .bind(&now) + .bind(crate::db::NO_INDEXED_AT) .bind(&now) .execute(&state.db) .await; diff --git a/tests/common/db.rs b/tests/common/db.rs index df601f7..9b3a14b 100644 --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -48,7 +48,7 @@ pub async fn truncate_all(pool: &AnyPool) { match backend { DatabaseBackend::Postgres => { happyview::db::query( - "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs, happyview_jobs, happyview_spaces, happyview_space_members, happyview_space_records, happyview_space_repo_state, happyview_space_record_oplog, happyview_space_notify_registrations, happyview_space_invites, happyview_linked_repo_sessions, happyview_linked_repo_auth_state, happyview_linked_repos, happyview_oauth_sessions RESTART IDENTITY CASCADE", + "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs, happyview_jobs, happyview_spaces, happyview_space_members, happyview_space_records, happyview_space_repo_state, happyview_space_record_oplog, happyview_space_notify_registrations, happyview_space_invites, happyview_linked_repo_sessions, happyview_linked_repo_auth_state, happyview_linked_repos, happyview_oauth_sessions, happyview_auth_login_redirects RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -59,6 +59,7 @@ pub async fn truncate_all(pool: &AnyPool) { "happyview_linked_repo_sessions", "happyview_linked_repo_auth_state", "happyview_linked_repos", + "happyview_auth_login_redirects", // Spaces tables (children before parents — no cascade on SQLite). "happyview_space_credentials", "happyview_space_dids", diff --git a/tests/lua_linked_repos_api.rs b/tests/lua_linked_repos_api.rs index 4c74a96..641c8aa 100644 --- a/tests/lua_linked_repos_api.rs +++ b/tests/lua_linked_repos_api.rs @@ -603,3 +603,133 @@ async fn global_is_registered_for_record_event_scripts() { assert_eq!(out["count"], 1); assert_eq!(out["did"], "did:plc:234567abcdefghijklmnopqr"); } + +// --------------------------------------------------------------------------- +// Local record index mirroring +// +// `pds::create_record` / `put_record` / `delete_record` keep `happyview_records` +// in step after a successful PDS write, the same way `Record:save()` does. The +// upsert deliberately swallows its errors — the PDS write has already landed — +// so a column/bind mismatch would be silent. These cover the statement itself. +// --------------------------------------------------------------------------- + +async fn fetch_indexed_row( + app: &TestApp, + uri: &str, +) -> Option<(String, String, String, String, Option)> { + let sql = happyview::db::adapt_sql( + "SELECT did, collection, rkey, cid, indexed_at FROM happyview_records WHERE uri = ?", + app.state.db_backend, + ); + happyview::db::query_as(&sql) + .bind(uri) + .fetch_optional(&app.state.db) + .await + .expect("fetch indexed row") +} + +#[tokio::test] +#[serial] +async fn index_write_mirrors_a_linked_repo_write() { + common::require_db!(); + let app = TestApp::new().await; + + let uri = "at://did:plc:target/games.gamesgamesgamesgames.game/abc123"; + happyview::linked_repos::pds::index_write( + &app.state, + "did:plc:target", + "games.gamesgamesgamesgames.game", + uri, + "bafyreiexample", + &serde_json::json!({ + "$type": "games.gamesgamesgamesgames.game", + "name": "Grand Space Odyssey", + }), + ) + .await; + + let (did, collection, rkey, cid, indexed_at) = + fetch_indexed_row(&app, uri).await.expect("row was indexed"); + assert_eq!(did, "did:plc:target"); + assert_eq!(collection, "games.gamesgamesgamesgames.game"); + assert_eq!(rkey, "abc123"); + // The real CID from the PDS response, not a placeholder — this is the + // reason the mirroring lives here rather than in Lua, where `save_local()` + // can only ever record NULL. + assert_eq!(cid, "bafyreiexample"); + // Not seen on the firehose yet, so no arrival time. + assert_eq!(indexed_at, None); +} + +#[tokio::test] +#[serial] +async fn index_write_preserves_network_indexed_at_on_update() { + common::require_db!(); + let app = TestApp::new().await; + + let uri = "at://did:plc:target/games.gamesgamesgamesgames.game/abc123"; + let record = serde_json::json!({"$type": "games.gamesgamesgamesgames.game", "name": "v1"}); + happyview::linked_repos::pds::index_write( + &app.state, + "did:plc:target", + "games.gamesgamesgamesgames.game", + uri, + "bafyv1", + &record, + ) + .await; + + // Pretend Jetstream echoed the record back and stamped its arrival. + let arrived = "2026-07-24T16:21:56.566+00:00"; + let sql = happyview::db::adapt_sql( + "UPDATE happyview_records SET indexed_at = ? WHERE uri = ?", + app.state.db_backend, + ); + happyview::db::query(&sql) + .bind(arrived) + .bind(uri) + .execute(&app.state.db) + .await + .expect("stamp indexed_at"); + + // A later linked-repo write to the same record. + happyview::linked_repos::pds::index_write( + &app.state, + "did:plc:target", + "games.gamesgamesgamesgames.game", + uri, + "bafyv2", + &serde_json::json!({"$type": "games.gamesgamesgamesgames.game", "name": "v2"}), + ) + .await; + + let (_, _, _, cid, indexed_at) = fetch_indexed_row(&app, uri).await.expect("row still there"); + assert_eq!(cid, "bafyv2", "the update landed"); + assert_eq!( + indexed_at.as_deref(), + Some(arrived), + "an AppView-side write must not overwrite network-arrival provenance", + ); +} + +#[tokio::test] +#[serial] +async fn unindex_write_removes_the_row() { + common::require_db!(); + let app = TestApp::new().await; + + let uri = "at://did:plc:target/games.gamesgamesgamesgames.game/abc123"; + happyview::linked_repos::pds::index_write( + &app.state, + "did:plc:target", + "games.gamesgamesgamesgames.game", + uri, + "bafyreiexample", + &serde_json::json!({"$type": "games.gamesgamesgamesgames.game", "name": "gone soon"}), + ) + .await; + assert!(fetch_indexed_row(&app, uri).await.is_some()); + + happyview::linked_repos::pds::unindex_write(&app.state, uri).await; + assert!(fetch_indexed_row(&app, uri).await.is_none()); +} diff --git a/tests/lua_record_api.rs b/tests/lua_record_api.rs index 1b9763b..9764601 100644 --- a/tests/lua_record_api.rs +++ b/tests/lua_record_api.rs @@ -129,6 +129,10 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> } } +/// Arrival time stamped on seeded rows, standing in for what the Jetstream +/// consumer would have written. +const SEEDED_INDEXED_AT: &str = "2026-07-24T16:21:56.566+00:00"; + async fn seed_record( pool: &sqlx::AnyPool, backend: DatabaseBackend, @@ -139,8 +143,8 @@ async fn seed_record( record: serde_json::Value, ) { let sql = adapt_sql( - "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", backend, ); happyview::db::query(&sql) @@ -150,12 +154,32 @@ async fn seed_record( .bind(rkey) .bind(serde_json::to_string(&record).unwrap_or_default()) .bind("bafyseed") + .bind(SEEDED_INDEXED_AT) .bind(now_rfc3339()) .execute(pool) .await .expect("failed to seed record"); } +/// Read back the two network-derived columns. `indexed_at` is `None` when SQL +/// NULL — the correct state for a record written locally and not yet echoed +/// back by Jetstream. +async fn fetch_provenance( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + uri: &str, +) -> Option<(String, Option)> { + let sql = adapt_sql( + "SELECT cid, indexed_at FROM happyview_records WHERE uri = ?", + backend, + ); + happyview::db::query_as(&sql) + .bind(uri) + .fetch_optional(pool) + .await + .expect("fetch provenance") +} + async fn count_records(pool: &sqlx::AnyPool, backend: DatabaseBackend, uri: &str) -> i64 { let sql = adapt_sql( "SELECT COUNT(*) FROM happyview_records WHERE uri = ?", @@ -342,6 +366,13 @@ async fn record_instance_save_local_updates_existing_row() { assert_eq!(body["$type"], "test.collection"); // Row count unchanged (upsert). assert_eq!(count_records(&pool, backend, uri).await, 1); + + // A local-only edit must not disturb the network-derived columns. The CID + // still describes what the PDS holds — redacting our copy doesn't change + // their copy — and it's what strongRefs are built from. + let (cid, indexed_at) = fetch_provenance(&pool, backend, uri).await.unwrap(); + assert_eq!(cid, "bafyseed"); + assert_eq!(indexed_at.as_deref(), Some(SEEDED_INDEXED_AT)); } #[tokio::test] @@ -375,6 +406,15 @@ async fn record_save_local_creates_new_row_when_repo_set() { let body = fetch_record_body(&pool, backend, &uri).await.unwrap(); assert_eq!(body["value"], 42); assert_eq!(body["$type"], "test.collection"); + + // This record has never existed on a PDS, so there is no CID describing it + // and it has never arrived from the network. + let (cid, indexed_at) = fetch_provenance(&pool, backend, &uri).await.unwrap(); + assert_eq!(cid, "", "no PDS version means no CID to record"); + // Asserts the explicit NULL bind rather than an omitted column: SQLite + // declares `indexed_at TEXT DEFAULT (datetime('now'))`, which would + // otherwise fabricate an arrival time on that backend but not on Postgres. + assert_eq!(indexed_at, None); } #[tokio::test]