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]