Commit

RFC 3339 timestamps in identity and repos

Times are stored and returned as UTC text such as 2026-10-02T05:16:19.000Z instead of Unix integers. Both databases are migrated; the work and events services follow when they are ported.

syntaqxcommitted Parent775f340Browse files
14 files+248−620/14 viewed
+3−2
167167 ];
168168
169169 /** Compact relative time such as "5m ago". */
170−export function TimeAgo({ at }: { at: number }) {
171− const elapsed = Date.now() - at;
170+export function TimeAgo({ at }: { at: string | number }) {
171+ // Timestamps are RFC 3339 strings; a few older ones are still numbers.
172+ const elapsed = Date.now() - new Date(at).getTime();
172173 const unit = UNITS.find(([, size]) => elapsed >= size);
173174 const label = unit ? `${Math.floor(elapsed / unit[1])}${unit[0]} ago` : "just now";
174175 return (
+4−3
1313 pub id: String,
1414 pub title: String,
1515 pub fingerprint: String,
16− /// Milliseconds since the epoch.
17− pub created_at: u64,
16+ /// RFC 3339.
17+ pub created_at: String,
1818 }
1919
2020 #[derive(Clone, Debug, Serialize, Deserialize)]
2222 pub struct AccessToken {
2323 pub id: String,
2424 pub name: String,
25− pub created_at: u64,
25+ /// RFC 3339.
26+ pub created_at: String,
2627 }
2728
2829 /// `sign_in`: verifies a username and password for website sign-in.
+1−0
1010 mod names;
1111 mod outcome;
1212 pub mod repos;
13+pub mod time;
1314
1415 pub use ids::new_id;
1516 pub use names::{is_valid_namespace, is_valid_repo_name};
+4−4
2020 pub default_branch: String,
2121 /// Set when this repo is an attempt's working copy of another repo.
2222 pub fork_of: Option<String>,
23− /// Milliseconds since the epoch.
24− pub created_at: u64,
23+ /// RFC 3339.
24+ pub created_at: String,
2525 }
2626
2727 #[derive(Clone, Debug, Serialize, Deserialize)]
4444 pub message: String,
4545 pub author: Signature,
4646 pub parents: Vec<String>,
47− /// Milliseconds since the epoch.
48− pub authored_at: u64,
47+ /// RFC 3339.
48+ pub authored_at: String,
4949 }
5050
5151 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+60−0
1+//! Timestamps. Everywhere in g1t, in storage and on the wire, a time is an
2+//! RFC 3339 string in UTC with millisecond precision, such as
3+//! `2026-10-02T05:16:19.000Z`. Strings in this one fixed format sort and
4+//! compare as text, so they need no parsing to be ordered.
5+
6+/// SQLite's expression for the current time in g1t's format.
7+pub const SQL_NOW: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
8+
9+/// SQLite's expression for a time `seconds` from now, in g1t's format.
10+pub fn sql_after(seconds: u64) -> String {
11+ format!("strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+{seconds} seconds')")
12+}
13+
14+/// Milliseconds since the Unix epoch as an RFC 3339 UTC timestamp.
15+pub fn rfc3339(ms: u64) -> String {
16+ let (seconds, millis) = (ms / 1000, ms % 1000);
17+ let (days, rest) = (seconds / 86_400, seconds % 86_400);
18+ let (hour, minute, second) = (rest / 3600, rest % 3600 / 60, rest % 60);
19+
20+ // Days since the epoch to a calendar date, after Howard Hinnant's
21+ // civil_from_days. The era arithmetic starts years in March.
22+ let z = days as i64 + 719_468;
23+ let era = z.div_euclid(146_097);
24+ let day_of_era = z.rem_euclid(146_097);
25+ let year_of_era =
26+ (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
27+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
28+ let shifted_month = (5 * day_of_year + 2) / 153;
29+ let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
30+ let month = if shifted_month < 10 {
31+ shifted_month + 3
32+ } else {
33+ shifted_month - 9
34+ };
35+ let year = year_of_era + era * 400 + i64::from(month <= 2);
36+
37+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
38+}
39+
40+#[cfg(test)]
41+mod tests {
42+ use super::*;
43+
44+ #[test]
45+ fn formats_known_instants() {
46+ assert_eq!(rfc3339(0), "1970-01-01T00:00:00.000Z");
47+ assert_eq!(rfc3339(951_782_400_000), "2000-02-29T00:00:00.000Z");
48+ assert_eq!(rfc3339(1_790_918_179_123), "2026-10-02T05:16:19.123Z");
49+ assert_eq!(rfc3339(4_102_444_799_999), "2099-12-31T23:59:59.999Z");
50+ }
51+
52+ #[test]
53+ fn later_times_sort_later_as_text() {
54+ let times: Vec<String> = [0, 999, 1000, 86_399_999, 86_400_000, 1_790_918_179_123]
55+ .into_iter()
56+ .map(rfc3339)
57+ .collect();
58+ assert!(times.windows(2).all(|pair| pair[0] < pair[1]));
59+ }
60+}
+3−2
1717 id: string;
1818 title: string;
1919 fingerprint: string;
20− createdAt: number;
20+ /** RFC 3339. */
21+ createdAt: string;
2122 };
2223
23−export type AccessToken = { id: string; name: string; createdAt: number };
24+export type AccessToken = { id: string; name: string; createdAt: string };
2425
2526 export type DeviceStart = {
2627 /** Secret held by the tool and exchanged for a token once approved. */
+4−2
1212 defaultBranch: string;
1313 /** Set when this repo is an attempt's working copy of another repo. */
1414 forkOf: string | null;
15− createdAt: number;
15+ /** RFC 3339. */
16+ createdAt: string;
1617 };
1718
1819 export type RepoPath = { namespace: string; name: string };
2324 message: string;
2425 author: { name: string; email: string };
2526 parents: string[];
26− authoredAt: number;
27+ /** RFC 3339. */
28+ authoredAt: string;
2729 };
2830
2931 export type TreeEntry = {
+84−0
1+-- Every timestamp becomes RFC 3339 UTC text (2026-10-02T05:16:19.000Z)
2+-- instead of Unix seconds. SQLite cannot change a column's type, so each
3+-- table is rebuilt and its rows converted.
4+PRAGMA defer_foreign_keys = on;
5+
6+CREATE TABLE users_new (
7+ id TEXT PRIMARY KEY,
8+ username TEXT NOT NULL UNIQUE,
9+ email TEXT,
10+ password_hash TEXT NOT NULL,
11+ email_verified_at TEXT,
12+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
13+);
14+INSERT INTO users_new (id, username, email, password_hash, email_verified_at, created_at)
15+SELECT id, username, email, password_hash,
16+ strftime('%Y-%m-%dT%H:%M:%fZ', email_verified_at, 'unixepoch'),
17+ strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch')
18+FROM users;
19+
20+-- id is the SHA-256 of the session token.
21+CREATE TABLE sessions_new (
22+ id TEXT PRIMARY KEY,
23+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
24+ expires_at TEXT NOT NULL
25+);
26+INSERT INTO sessions_new (id, user_id, expires_at)
27+SELECT id, user_id, strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch') FROM sessions;
28+
29+CREATE TABLE access_tokens_new (
30+ id TEXT PRIMARY KEY,
31+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
32+ name TEXT NOT NULL,
33+ token_hash TEXT NOT NULL UNIQUE,
34+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
35+ -- Null means the token does not expire.
36+ expires_at TEXT
37+);
38+INSERT INTO access_tokens_new (id, user_id, name, token_hash, created_at, expires_at)
39+SELECT id, user_id, name, token_hash,
40+ strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch'),
41+ strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch')
42+FROM access_tokens;
43+
44+CREATE TABLE ssh_keys_new (
45+ id TEXT PRIMARY KEY,
46+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
47+ title TEXT NOT NULL,
48+ public_key TEXT NOT NULL,
49+ fingerprint TEXT NOT NULL UNIQUE,
50+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
51+);
52+INSERT INTO ssh_keys_new (id, user_id, title, public_key, fingerprint, created_at)
53+SELECT id, user_id, title, public_key, fingerprint,
54+ strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch')
55+FROM ssh_keys;
56+
57+-- One-time links sent by email. id is the SHA-256 of the token in the link.
58+CREATE TABLE email_tokens_new (
59+ id TEXT PRIMARY KEY,
60+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
61+ -- 'verify' or 'reset'
62+ kind TEXT NOT NULL,
63+ expires_at TEXT NOT NULL
64+);
65+INSERT INTO email_tokens_new (id, user_id, kind, expires_at)
66+SELECT id, user_id, kind, strftime('%Y-%m-%dT%H:%M:%fZ', expires_at, 'unixepoch')
67+FROM email_tokens;
68+
69+DROP TABLE sessions;
70+DROP TABLE access_tokens;
71+DROP TABLE ssh_keys;
72+DROP TABLE email_tokens;
73+DROP TABLE users;
74+
75+ALTER TABLE users_new RENAME TO users;
76+ALTER TABLE sessions_new RENAME TO sessions;
77+ALTER TABLE access_tokens_new RENAME TO access_tokens;
78+ALTER TABLE ssh_keys_new RENAME TO ssh_keys;
79+ALTER TABLE email_tokens_new RENAME TO email_tokens;
80+
81+CREATE UNIQUE INDEX users_email ON users (email) WHERE email IS NOT NULL;
82+CREATE INDEX access_tokens_user ON access_tokens (user_id);
83+CREATE INDEX ssh_keys_user ON ssh_keys (user_id);
84+CREATE INDEX email_tokens_user ON email_tokens (user_id, kind);
+7−7
77 //! creation and passwords stay in the browser, where they can be protected.
88
99 use g1t_contracts::identity::*;
10+use g1t_contracts::time::{SQL_NOW, sql_after};
1011 use g1t_contracts::{FailureCode, Outcome, User};
1112 use serde::Deserialize;
1213 use worker::Result;
1314
1415 use crate::{Identity, crypto};
1516
16−const EXPIRES_IN_SECONDS: u32 = 15 * 60;
17+const EXPIRES_IN_SECONDS: u64 = 15 * 60;
1718 const POLL_INTERVAL_SECONDS: u32 = 5;
1819 /// No vowels, so a code never spells a word, and nothing easily confused.
1920 const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
20−/// SQLite's expression for the current time as RFC 3339 UTC text.
21−const NOW: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
2221
2322 #[derive(Deserialize)]
2423 struct DeviceRow {
6362 self.db
6463 .prepare(format!(
6564 "INSERT INTO device_codes (id, user_code, client_name, expires_at)
66− VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+{EXPIRES_IN_SECONDS} seconds'))"
65+ VALUES (?, ?, ?, {})",
66+ sql_after(EXPIRES_IN_SECONDS)
6767 ))
6868 .bind(&[
6969 crypto::sha256_hex(&device_code).into(),
7575 Ok(DeviceStart {
7676 device_code,
7777 user_code,
78− expires_in: EXPIRES_IN_SECONDS,
78+ expires_in: EXPIRES_IN_SECONDS as u32,
7979 interval: POLL_INTERVAL_SECONDS,
8080 })
8181 }
8585 self.db
8686 .prepare(format!(
8787 "SELECT user_code, client_name, status, user_id FROM device_codes
88− WHERE user_code = ? AND status = 'pending' AND expires_at > {NOW}"
88+ WHERE user_code = ? AND status = 'pending' AND expires_at > {SQL_NOW}"
8989 ))
9090 .bind(&[normalize(user_code).into()])?
9191 .first::<DeviceRow>(None)
127127 .db
128128 .prepare(format!(
129129 "SELECT user_code, client_name, status, user_id FROM device_codes
130− WHERE id = ? AND expires_at > {NOW}"
130+ WHERE id = ? AND expires_at > {SQL_NOW}"
131131 ))
132132 .bind(&[id.as_str().into()])?
133133 .first::<DeviceRow>(None)
+43−36
88 mod email;
99
1010 use g1t_contracts::identity::*;
11+use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
1112 use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
1213 use g1t_kit::{args, now_ms, reply, rpc_method};
1314 use serde::Deserialize;
1415 use worker::wasm_bindgen::JsValue;
1516 use worker::{Context, D1Database, Env, Request, Response, Result, event};
1617
17−const SESSION_TTL_SECONDS: u32 = 30 * 24 * 60 * 60;
18−const VERIFY_TTL_SECONDS: u32 = 24 * 60 * 60;
19−const RESET_TTL_SECONDS: u32 = 60 * 60;
18+const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
19+const VERIFY_TTL_SECONDS: u64 = 24 * 60 * 60;
20+const RESET_TTL_SECONDS: u64 = 60 * 60;
2021 const TOKEN_PREFIX: &str = "g1t_";
2122 const MIN_PASSWORD_LENGTH: usize = 10;
2223 const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
6061 id: String,
6162 title: String,
6263 fingerprint: String,
63− created_at: u64,
64+ created_at: String,
6465 }
6566
6667 impl From<KeyRow> for SshKey {
6970 id: row.id,
7071 title: row.title,
7172 fingerprint: row.fingerprint,
72− created_at: row.created_at * 1000,
73+ created_at: row.created_at,
7374 }
7475 }
7576 }
7879 struct TokenRow {
7980 id: String,
8081 name: String,
81− created_at: u64,
82+ created_at: String,
8283 }
8384
8485 impl From<TokenRow> for AccessToken {
8687 AccessToken {
8788 id: row.id,
8889 name: row.name,
89− created_at: row.created_at * 1000,
90+ created_at: row.created_at,
9091 }
9192 }
9293 }
109110 }
110111
111112 /// Stores a one-time token of `kind` for the user and returns it.
112− async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u32) -> Result<String> {
113+ async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> {
113114 let token = crypto::random_hex(32);
114115 self.db
115− .prepare(
116+ .prepare(format!(
116117 "INSERT INTO email_tokens (id, user_id, kind, expires_at)
117− VALUES (?, ?, ?, unixepoch() + ?)",
118− )
118+ VALUES (?, ?, ?, {})",
119+ sql_after(ttl)
120+ ))
119121 .bind(&[
120122 crypto::sha256_hex(&token).into(),
121123 user_id.into(),
122124 kind.into(),
123− ttl.into(),
124125 ])?
125126 .run()
126127 .await?;
132133 let id = crypto::sha256_hex(token);
133134 let owner = self
134135 .db
135− .prepare(
136+ .prepare(format!(
136137 "SELECT users.id, users.username, users.email FROM email_tokens
137138 JOIN users ON users.id = email_tokens.user_id
138139 WHERE email_tokens.id = ? AND email_tokens.kind = ?
139− AND email_tokens.expires_at > unixepoch()",
140− )
140+ AND email_tokens.expires_at > {SQL_NOW}"
141+ ))
141142 .bind(&[id.as_str().into(), kind.into()])?
142143 .first::<TokenOwner>(None)
143144 .await?;
189190 ));
190191 };
191192 self.db
192− .prepare("UPDATE users SET email_verified_at = unixepoch() WHERE id = ?")
193+ .prepare(format!(
194+ "UPDATE users SET email_verified_at = {SQL_NOW} WHERE id = ?"
195+ ))
193196 .bind(&[owner.id.as_str().into()])?
194197 .run()
195198 .await?;
234237 };
235238 // Following an emailed link also proves the address.
236239 self.db
237− .prepare(
240+ .prepare(format!(
238241 "UPDATE users SET password_hash = ?,
239− email_verified_at = COALESCE(email_verified_at, unixepoch())
240− WHERE id = ?",
241− )
242+ email_verified_at = COALESCE(email_verified_at, {SQL_NOW})
243+ WHERE id = ?"
244+ ))
242245 .bind(&[
243246 crypto::hash_password(&a.password).into(),
244247 owner.id.as_str().into(),
340343 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
341344 let session_token = crypto::random_hex(32);
342345 self.db
343− .prepare(
344− "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)",
345− )
346+ .prepare(format!(
347+ "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, {})",
348+ sql_after(SESSION_TTL_SECONDS)
349+ ))
346350 .bind(&[
347351 crypto::sha256_hex(&session_token).into(),
348352 user.id.as_str().into(),
349− SESSION_TTL_SECONDS.into(),
350353 ])?
351354 .run()
352355 .await?;
367370
368371 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
369372 self.find_user(
370− "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM sessions
371− JOIN users ON users.id = sessions.user_id
372− WHERE sessions.id = ? AND sessions.expires_at > unixepoch()",
373+ &format!(
374+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
375+ FROM sessions JOIN users ON users.id = sessions.user_id
376+ WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
377+ ),
373378 &crypto::sha256_hex(&a.session_token),
374379 )
375380 .await
380385 return Ok(None);
381386 }
382387 self.find_user(
383− "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM access_tokens
384− JOIN users ON users.id = access_tokens.user_id
385− WHERE token_hash = ?
386− AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > unixepoch())",
388+ &format!(
389+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
390+ FROM access_tokens JOIN users ON users.id = access_tokens.user_id
391+ WHERE token_hash = ?
392+ AND (access_tokens.expires_at IS NULL OR access_tokens.expires_at > {SQL_NOW})"
393+ ),
387394 &crypto::sha256_hex(token),
388395 )
389396 .await
456463 id: new_id("key", now),
457464 title,
458465 fingerprint: key.fingerprint,
459− created_at: now / 1000,
466+ created_at: rfc3339(now),
460467 };
461468 self.db
462469 .prepare(
469476 row.title.as_str().into(),
470477 key.public_key.into(),
471478 row.fingerprint.as_str().into(),
472− (row.created_at as f64).into(),
479+ row.created_at.as_str().into(),
473480 ])?
474481 .run()
475482 .await?;
501508 let row = TokenRow {
502509 id: new_id("tok", now),
503510 name: name.to_owned(),
504− created_at: now / 1000,
511+ created_at: rfc3339(now),
505512 };
506513 self.db
507514 .prepare(
513520 a.user.id.into(),
514521 row.name.as_str().into(),
515522 crypto::sha256_hex(&token).into(),
516− (row.created_at as f64).into(),
523+ row.created_at.as_str().into(),
517524 a.ttl_seconds
518− .map_or(JsValue::NULL, |ttl| ((row.created_at + ttl) as f64).into()),
525+ .map_or(JsValue::NULL, |ttl| rfc3339(now + ttl * 1000).into()),
519526 ])?
520527 .run()
521528 .await?;
+27−0
1+-- created_at becomes RFC 3339 UTC text instead of Unix seconds.
2+PRAGMA defer_foreign_keys = on;
3+
4+-- Stored in Artifacts as "<namespace>--<name>".
5+CREATE TABLE repos_new (
6+ id TEXT PRIMARY KEY,
7+ namespace TEXT NOT NULL,
8+ name TEXT NOT NULL,
9+ description TEXT,
10+ is_private INTEGER NOT NULL DEFAULT 0,
11+ owner_id TEXT NOT NULL,
12+ default_branch TEXT NOT NULL DEFAULT 'main',
13+ -- Set on an attempt's working copy; those are hidden from listings.
14+ -- Refers to this table; the rename below carries the reference along.
15+ fork_of TEXT REFERENCES repos_new (id),
16+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17+ UNIQUE (namespace, name)
18+);
19+INSERT INTO repos_new
20+ (id, namespace, name, description, is_private, owner_id, default_branch, fork_of, created_at)
21+SELECT id, namespace, name, description, is_private, owner_id, default_branch, fork_of,
22+ strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch')
23+FROM repos;
24+
25+DROP TABLE repos;
26+ALTER TABLE repos_new RENAME TO repos;
27+CREATE INDEX repos_owner ON repos (owner_id);
+3−2
1313
1414 use g1t_contracts::events::{GitPush, NewEvent, RepoCreated, RepoForked};
1515 use g1t_contracts::repos::*;
16+use g1t_contracts::time::rfc3339;
1617 use g1t_contracts::{
1718 FailureCode, Outcome, User, Viewer, is_valid_namespace, is_valid_repo_name, new_id,
1819 };
194195 owner_id: a.owner.id.clone(),
195196 default_branch: "main".to_owned(),
196197 fork_of: None,
197− created_at: now,
198+ created_at: rfc3339(now),
198199 };
199200 self.store
200201 .create(
343344 owner_id: a.actor.id.clone(),
344345 default_branch: source.default_branch.clone(),
345346 fork_of: Some(source.id.clone()),
346− created_at: now,
347+ created_at: rfc3339(now),
347348 };
348349 self.store
349350 .open(&store_key(&source))
+3−3
1616 owner_id: String,
1717 default_branch: String,
1818 fork_of: Option<String>,
19− created_at: u64,
19+ created_at: String,
2020 }
2121
2222 impl From<RepoRow> for Repo {
3030 owner_id: row.owner_id,
3131 default_branch: row.default_branch,
3232 fork_of: row.fork_of,
33− created_at: row.created_at * 1000,
33+ created_at: row.created_at,
3434 }
3535 }
3636 }
139139 repo.owner_id.as_str().into(),
140140 repo.default_branch.as_str().into(),
141141 optional(&repo.fork_of),
142− ((repo.created_at / 1000) as f64).into(),
142+ repo.created_at.as_str().into(),
143143 ])?
144144 .run()
145145 .await?;
+2−1
44 //! [`ArtifactsStore`] is the adapter for Cloudflare Artifacts.
55
66 use g1t_contracts::repos::{Commit, EntryKind, GitAccess, Signature, TreeEntry};
7+use g1t_contracts::time::rfc3339;
78 use g1t_kit::js;
89 use serde::Deserialize;
910 use worker::js_sys::{Reflect, Uint8Array};
180181 message: commit.message,
181182 author: commit.author,
182183 parents: commit.parents,
183− authored_at: commit.authored_at * 1000,
184+ authored_at: rfc3339(commit.authored_at * 1000),
184185 })
185186 .collect())
186187 }