diff --git a/src/appview/tests.rs b/src/appview/tests.rs index 44cb4f5..c980c6f 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -79,8 +79,10 @@ async fn seed_thing( like_count: i64, ) -> String { let uri = format!("at://{did}/space.polymodel.library.thing/{rkey}"); - let now = chrono::Utc::now().timestamp_millis(); - let created = now - 86_400_000; // 1 day ago by default + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); + let created = now - 86_400_000_000_000; // 1 day ago by default sqlx::query( r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) @@ -134,7 +136,9 @@ async fn seed_model( parts: &[&str], ) -> String { let uri = format!("at://{did}/space.polymodel.library.model/{rkey}"); - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query( r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)"#, @@ -172,7 +176,9 @@ async fn seed_model( async fn seed_part(pool: &SqlitePool, did: &str, rkey: &str, name: &str) -> String { let uri = format!("at://{did}/space.polymodel.library.part/{rkey}"); - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query( r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)"#, @@ -204,7 +210,11 @@ async fn seed_identity(pool: &SqlitePool, did: &str, handle: &str) { ) .bind(did) .bind(handle) - .bind(chrono::Utc::now().timestamp_millis()) + .bind( + chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"), + ) .execute(pool) .await .unwrap(); @@ -223,7 +233,11 @@ async fn seed_profile(pool: &SqlitePool, did: &str, display: &str) { .bind(display) .bind(avatar) .bind(json!({"displayName": display, "avatar": blob("bafkreiavatar")}).to_string()) - .bind(chrono::Utc::now().timestamp_millis()) + .bind( + chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"), + ) .execute(pool) .await .unwrap(); diff --git a/src/appview/views.rs b/src/appview/views.rs index b3ec78c..bd498f1 100644 --- a/src/appview/views.rs +++ b/src/appview/views.rs @@ -55,9 +55,8 @@ fn did_of(did: &str) -> AppResult { fn handle_of(handle: String) -> AppResult { Handle::new_owned(handle).map_err(|e| internal(format!("invalid handle: {e}"))) } -fn datetime_millis(millis: i64) -> Datetime { - let dt = chrono::DateTime::::from_timestamp_millis(millis) - .unwrap_or_else(chrono::Utc::now); +fn datetime_nanos(nanos: i64) -> Datetime { + let dt = chrono::DateTime::::from_timestamp_nanos(nanos); Datetime::new(dt.fixed_offset()) } @@ -332,7 +331,7 @@ async fn thing_view( cid: s(row.cid), author, record: record_data(row.record_json.as_deref())?, - indexed_at: datetime_millis(row.indexed_at), + indexed_at: datetime_nanos(row.indexed_at), name: s(row.name), summary: row.summary.map(s), license: s(row.license), @@ -361,7 +360,7 @@ async fn thing_basic_view( cid: s(row.cid), name: s(row.name), author: actor_for_did(state, &row.did).await?, - indexed_at: datetime_millis(row.indexed_at), + indexed_at: datetime_nanos(row.indexed_at), like_count: row.like_count, save_count: row.save_count, model_count: row.model_count, @@ -387,7 +386,7 @@ async fn model_view( cid: s(row.cid), author, record: record_data(row.record_json.as_deref())?, - indexed_at: datetime_millis(row.indexed_at), + indexed_at: datetime_nanos(row.indexed_at), name: s(row.name), summary: row.summary.map(s), part_count: row.part_count, @@ -411,7 +410,7 @@ async fn part_view( cid: s(row.cid), author, record: record_data(row.record_json.as_deref())?, - indexed_at: datetime_millis(row.indexed_at), + indexed_at: datetime_nanos(row.indexed_at), name: s(row.name), format: row.format.map(s), file: file_data(Some(row.file_json.as_str()))?, @@ -691,7 +690,9 @@ pub(super) async fn feed_hot( .fetch_all(&state.pool) .await, )?; - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); let mut scored: Vec<(f64, ThingRow)> = rows.into_iter().map(|r| (hot_score(&r, now), r)).collect(); // score desc, tie-break rkey desc (deterministic, reproducible page boundary). @@ -715,8 +716,8 @@ pub(super) async fn feed_hot( feed_view(state, page, cursor, viewer_did).await } -fn hot_score(row: &ThingRow, now_millis: i64) -> f64 { - let age_hours = ((now_millis - row.created_at).max(0) as f64) / 3_600_000.0; +fn hot_score(row: &ThingRow, now_nanos: i64) -> f64 { + let age_hours = ((now_nanos - row.created_at).max(0) as f64) / 3_600_000_000_000.0; let engagement = row.like_count as f64 + row.save_count as f64; engagement / (age_hours + 2.0).powf(1.5) } @@ -950,7 +951,7 @@ pub(super) async fn polymodel_profile_view( links: uri_vec(row.links_json.as_deref()), thing_count: Some(stats.thing_count), total_likes: Some(stats.total_likes), - indexed_at: Some(datetime_millis(row.indexed_at)), + indexed_at: Some(datetime_nanos(row.indexed_at)), extra_data: None, })) } diff --git a/src/indexing/db.rs b/src/indexing/db.rs index cdb35e4..6e933f7 100644 --- a/src/indexing/db.rs +++ b/src/indexing/db.rs @@ -73,7 +73,9 @@ pub async fn advance_cursor(db: &SqlitePool, cursor: u64) -> anyhow::Result<()> /// Persist the projection cursor inside an in-flight projection transaction. pub async fn save_cursor(tx: &mut Transaction<'_, Sqlite>, cursor: u64) -> anyhow::Result<()> { - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); sqlx::query!( "INSERT INTO projection_state (key, value, updated_at) VALUES ('cursor', ?, ?) \ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", diff --git a/src/indexing/projection.rs b/src/indexing/projection.rs index 490f6b3..0fb6d11 100644 --- a/src/indexing/projection.rs +++ b/src/indexing/projection.rs @@ -211,14 +211,16 @@ fn str_array(v: &Value, key: &str) -> Vec { .unwrap_or_default() } -fn parse_millis(v: &Value, key: &str) -> Option { +fn parse_nanos(v: &Value, key: &str) -> Option { chrono::DateTime::parse_from_rfc3339(v.get(key)?.as_str()?) .ok() - .map(|dt| dt.timestamp_millis()) + .and_then(|dt| dt.timestamp_nanos_opt()) } -fn now_millis() -> i64 { - chrono::Utc::now().timestamp_millis() +fn now_nanos() -> i64 { + chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds") } fn at_uri(did: &str, collection: &str, rkey: &str) -> String { @@ -263,8 +265,8 @@ async fn project_thing_upsert( Some(tags.join(" ")) }; - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); - let indexed_at = now_millis(); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); + let indexed_at = now_nanos(); // The full raw record, so read endpoints can populate the required `record` // field on thingView directly from SQLite (and the write path gets // read-your-own-write, since Hydrant has no public single-record write API). @@ -370,8 +372,8 @@ async fn project_model_upsert( return Ok(()); }; let summary = s_str(rec, "summary"); - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); - let indexed_at = now_millis(); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); + let indexed_at = now_nanos(); let record_json = rec.to_string(); sqlx::query!( @@ -446,8 +448,8 @@ async fn project_part_upsert( }; let format = s_str(rec, "format"); let file_json = rec.get("file").map(|v| v.to_string()); - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); - let indexed_at = now_millis(); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); + let indexed_at = now_nanos(); let record_json = rec.to_string(); sqlx::query!( @@ -519,7 +521,7 @@ async fn project_profile_upsert( .and_then(|v| v.as_array()) .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string())); let record_json = rec.to_string(); - let indexed_at = now_millis(); + let indexed_at = now_nanos(); sqlx::query!( "INSERT INTO profiles \ @@ -565,7 +567,7 @@ async fn project_identity_upsert( tx: &mut Transaction<'_, Sqlite>, e: &IdentityProjection, ) -> anyhow::Result<()> { - let updated_at = now_millis(); + let updated_at = now_nanos(); sqlx::query!( "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?) \ ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, \ @@ -598,7 +600,7 @@ async fn project_like_create( let Some(subject) = ref_uri(rec, "subject") else { return Ok(()); }; - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); let res = sqlx::query!( "INSERT OR IGNORE INTO likes (did, rkey, subject_uri, created_at) VALUES (?, ?, ?, ?)", @@ -667,7 +669,7 @@ async fn project_save_create( return Ok(()); }; let note = s_str(rec, "note"); - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); let res = sqlx::query!( "INSERT OR IGNORE INTO saves (did, rkey, subject_uri, note, created_at) \ @@ -740,7 +742,7 @@ async fn project_tag_create( let Some(tag) = s_str(rec, "tag") else { return Ok(()); }; - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); let res = sqlx::query!( "INSERT OR IGNORE INTO tags (did, rkey, subject_uri, tag, created_at) \ @@ -809,7 +811,7 @@ async fn project_listitem_create( let Some(subject_uri) = ref_uri(rec, "subject") else { return Ok(()); }; - let created_at = parse_millis(rec, "createdAt").unwrap_or_else(now_millis); + let created_at = parse_nanos(rec, "createdAt").unwrap_or_else(now_nanos); // No denormalized count for listitems; list membership is queried directly. sqlx::query!( diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 665d361..b5a1f0a 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -84,7 +84,9 @@ impl ClientAuthStore for SqliteAuthStore { } async fn upsert_session(&self, session: ClientSessionData) -> Result<(), SessionStoreError> { - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); let payload = serde_json::to_string(&session)?; sqlx::query!( "INSERT INTO oauth_sessions @@ -154,7 +156,9 @@ impl ClientAuthStore for SqliteAuthStore { &self, auth_req_info: &AuthRequestData, ) -> Result<(), SessionStoreError> { - let now = chrono::Utc::now().timestamp_millis(); + let now = chrono::Utc::now() + .timestamp_nanos_opt() + .expect("current timestamp must fit in i64 nanoseconds"); let payload = serde_json::to_string(auth_req_info)?; sqlx::query!( "INSERT INTO oauth_auth_requests