The archive, kept where every isolate sees the same rows: one Durable
Object with a SQL table of every request that was answered, and one of
the questions people asked. The messages are archive's.
The same request is never sent to the same model twice (the user,
2026-10-02), unless a visitor asks for it to be (Pick::Fresh, the page's
↻), and then every response it got is kept, numbered. Three things make
that hold:
versionshas(sent_to, request, version)as its primary key: the exact body, where it went, and which time. No hash stands in for the body, so two different requests cannot be taken for one.- A call is looked up before anything is spent, and its response is kept before anyone is told.
- The archive makes the call itself. Identical asks that arrive while it
is on the wire wait on that one call (
running) and are told what it was told. The call is handed towait_until, so it finishes and is kept even if every visitor waiting on it has left.
A call with no response (an error, a timeout) keeps nothing: there is nothing to return next time, so the next ask sends it.
27use archive::{Unsaid, Answer, Ask, Asker, Called, Cursor, Entry, Pick, Place, Rating, Seen, Vote, FEED, Fetch, Home, Live, MOST, Pot, Record, Stats, Told}; 28use ask::{Outcome, Wanted}; 29use budget::Which; 30use futures_util::FutureExt; 31use futures_util::future::{LocalBoxFuture, Shared}; 32use jev_client::Runtime; 33use jev_worker::WorkerRuntime; 34use llm::Model; 35use serde::Deserialize; 36use worker::{DurableObject, Env, Request, Response, SqlStorage, SqlStorageValue, State, WebSocket, WebSocketIncomingMessage, WebSocketPair, console_error, durable_object}; 37 38use crate::meter::{self, Refused}; 39use crate::past::Past; 40use crate::{NoJev, ai};
The binding's name in wrangler.toml.
43const BINDING: &str = "ARCHIVE";
One object for the whole Worker: a request anyone had answered is answered for everyone.
46const NAME: &str = ::archive::OBJECT;
The schema, as the steps that made it, in order. A step runs once: the
object records how many it has run (migrated) and runs the rest when it
starts. Never edit a step that has shipped; add one.
calls.sent_to is Jev's endpoint or a Workers AI model id. (A Jev body
names its own model, so the endpoint is enough.)
54const MIGRATIONS: [&[&str]; 21] = [ 55 // Written `IF NOT EXISTS` because this step first shipped before 56 // `migrated` existed, so it meets its own tables on an older object. 57 &[ 58 "CREATE TABLE IF NOT EXISTS calls ( 59 sent_to TEXT NOT NULL, 60 request TEXT NOT NULL, 61 response TEXT NOT NULL, 62 request_id TEXT, 63 attempts INTEGER NOT NULL, 64 took_ms REAL NOT NULL, 65 answered_ms REAL NOT NULL, 66 PRIMARY KEY (sent_to, request) 67 )", 68 "CREATE TABLE IF NOT EXISTS asked ( 69 input TEXT PRIMARY KEY, 70 answers TEXT NOT NULL, 71 asked_ms REAL NOT NULL 72 )", 73 "CREATE INDEX IF NOT EXISTS asked_lately ON asked (asked_ms DESC)", 74 ], 75 // How often a question was asked, whether it may be shown, whether it 76 // needed an LLM (every question before this step did), and the tally of 77 // requests sent and not sent. 78 &[ 79 "ALTER TABLE asked ADD COLUMN times INTEGER NOT NULL DEFAULT 1", 80 "ALTER TABLE asked ADD COLUMN listed INTEGER NOT NULL DEFAULT 1", 81 "ALTER TABLE asked ADD COLUMN llm INTEGER NOT NULL DEFAULT 1", 82 "CREATE TABLE counts (name TEXT PRIMARY KEY, n INTEGER NOT NULL)", 83 ], 84 // A correction to the data, not the schema. In the first ten minutes 85 // these counts existed (2026-10-02), nearly everything counted was the 86 // agent load-testing the live site: one question reached "asked 67 87 // times". Counting starts again from here, and the question the agent 88 // invented to test the feed's moderation is removed. 89 &[ 90 "UPDATE asked SET times = 1", 91 "DELETE FROM asked WHERE input = 'is my neighbour John Smith of 12 Elm Street a paedophile?'", 92 "DELETE FROM counts", 93 ], 94 // A second correction (2026-10-02): the agent checked four deploys by 95 // asking the live site this question, which was already kept, so each 96 // check sent nothing and only counted. It had been asked 3 times; the 97 // checks made it 7. Deploys are now checked with `/gate` on new text. 98 &["UPDATE asked SET times = times - 4 WHERE input = 'Is a slot machine a good retirement plan?' AND times >= 5"], 99 // Each browser counts once per question (the owner, 2026-10-02): a 100 // question's `times` goes up only for a browser not in `askers` for it. 101 // Counts from before stay as they were. 102 &["CREATE TABLE askers (input TEXT NOT NULL, who TEXT NOT NULL, PRIMARY KEY (input, who))"], 103 // Votes on Jev's answers (the owner, 2026-10-02): one per browser per 104 // answer, `answer` being the SHA-256 of the answers as kept, so an 105 // answer that changes starts again from no votes. 106 &["CREATE TABLE ratings (input TEXT NOT NULL, answer TEXT NOT NULL, who TEXT NOT NULL, vote INTEGER NOT NULL, PRIMARY KEY (input, answer, who))"], 107 // A request may be sent again when a visitor asks (the owner, 108 // 2026-10-02: the page's ↻), and every response it got is kept: `calls` 109 // becomes `versions`, numbered from 1, and what was kept is version 1. 110 &[ 111 "CREATE TABLE versions ( 112 sent_to TEXT NOT NULL, 113 request TEXT NOT NULL, 114 version INTEGER NOT NULL, 115 response TEXT NOT NULL, 116 request_id TEXT, 117 attempts INTEGER NOT NULL, 118 took_ms REAL NOT NULL, 119 answered_ms REAL NOT NULL, 120 PRIMARY KEY (sent_to, request, version) 121 )", 122 "INSERT INTO versions SELECT sent_to, request, 1, response, request_id, attempts, took_ms, answered_ms FROM calls", 123 "DROP TABLE calls", 124 ], 125 // Nothing is dropped (the owner, 2026-10-03): every event is kept whole 126 // in `events`, whose columns after `at_ms` are `archive::Event::COLUMNS` 127 // (a test holds the two together). And the owner may overrule the rules 128 // on what the feed shows: `moderated` is their say, NULL while they have 129 // none, and `listed` stays Jev's. 130 &[ 131 "CREATE TABLE events ( 132 id INTEGER PRIMARY KEY AUTOINCREMENT, 133 at_ms REAL NOT NULL, 134 what TEXT NOT NULL, 135 method TEXT NOT NULL, 136 host TEXT NOT NULL, 137 path TEXT NOT NULL, 138 query TEXT NOT NULL, 139 input TEXT NOT NULL, 140 detail TEXT NOT NULL, 141 status REAL NOT NULL, 142 sent REAL NOT NULL, 143 kept REAL NOT NULL, 144 llm REAL NOT NULL, 145 took_ms REAL NOT NULL, 146 first REAL NOT NULL, 147 daily REAL NOT NULL, 148 session REAL NOT NULL, 149 referrer TEXT NOT NULL, 150 source TEXT NOT NULL, 151 client TEXT NOT NULL, 152 family TEXT NOT NULL, 153 os TEXT NOT NULL, 154 device TEXT NOT NULL, 155 language TEXT NOT NULL, 156 agent TEXT NOT NULL, 157 browser TEXT NOT NULL, 158 ip TEXT NOT NULL, 159 country TEXT NOT NULL, 160 region TEXT NOT NULL, 161 city TEXT NOT NULL, 162 postcode TEXT NOT NULL, 163 timezone TEXT NOT NULL, 164 latitude REAL, 165 longitude REAL, 166 asn REAL, 167 network TEXT NOT NULL, 168 colo TEXT NOT NULL, 169 protocol TEXT NOT NULL 170 )", 171 "CREATE INDEX events_lately ON events (at_ms DESC)", 172 "CREATE INDEX events_browser ON events (browser, at_ms)", 173 "CREATE INDEX events_ip ON events (ip, at_ms)", 174 "ALTER TABLE asked ADD COLUMN moderated INTEGER", 175 ], 176 // What only the page can say (the owner, 2026-10-03, of what an 177 // analytics product would have): the link's other campaign tags, the 178 // screen and window, and how far down a page was seen. `read` and `out` 179 // events carry them (`POST /seen`). 180 &[ 181 "ALTER TABLE events ADD COLUMN medium TEXT NOT NULL DEFAULT ''", 182 "ALTER TABLE events ADD COLUMN campaign TEXT NOT NULL DEFAULT ''", 183 "ALTER TABLE events ADD COLUMN screen TEXT NOT NULL DEFAULT ''", 184 "ALTER TABLE events ADD COLUMN viewport TEXT NOT NULL DEFAULT ''", 185 "ALTER TABLE events ADD COLUMN scroll REAL NOT NULL DEFAULT 0", 186 ], 187 // `who` is a hash of a browser's id and the question, so it is 188 // different on every row and says nothing to someone reading the table 189 // (the owner, 2026-10-03: "this is the same guy over and over"). The 190 // browser's id is kept beside it. `who` stays the key: it is still what 191 // counts a browser once per question. Rows from before are filled in 192 // from `events` where they can be (`ASKERS_NAMED`). 193 &["ALTER TABLE askers ADD COLUMN browser TEXT NOT NULL DEFAULT ''", "ALTER TABLE ratings ADD COLUMN browser TEXT NOT NULL DEFAULT ''"], 194 // The feeds in the order they are read, so a page of one is the rows it 195 // shows and not the whole table sorted: on 2026-10-03 the archive read 196 // three million rows by mid-morning, of five a day, and every home page 197 // was a scan of every question. (The old `asked_lately` has only 198 // `asked_ms`, and the feed's order breaks ties by `input`.) 199 &["CREATE INDEX asked_feed ON asked (asked_ms DESC, input DESC)", "CREATE INDEX asked_most ON asked (times DESC, asked_ms DESC)"], 200 // Views for the owner's backend to drill into (the owner, 2026-10-03: 201 // "i like pushing logic down into the database"). Each tree is two 202 // views. `page_hits` is every event once for its page and once for each 203 // folder above it, so `/lmjtfy.git/apps/x` is a hit on `/lmjtfy.git`, 204 // on `/lmjtfy.git/apps` and on itself; `pages` adds those up, so 205 // `WHERE parent IS NULL` is the top of the site and `WHERE parent = 206 // '/lmjtfy.git'` is what is under it. `place_hits` and `places` are the 207 // same for country, region, city and network. A `_hits` view keeps 208 // `at_ms`, and a condition on it reaches the index on `events`; the 209 // added-up views are of all time. A path is split with `json_each`, 210 // since SQLite has no function for it and an object's SQLite takes none 211 // of our own. 212 &[r#"CREATE VIEW page_hits AS 213SELECT h.event, h.at_ms, h.parent, h.page, CASE WHEN h.name = '' THEN '/' ELSE h.name END AS name, h.depth, 214 (h.page = h.path OR h.page || '/' = h.path) AS landed, h.what, h.client, h.status, h.browser, h.ip, h.country 215FROM ( 216 SELECT e.id AS event, e.at_ms, e.path, s.key AS depth, s.value AS name, 217 CASE WHEN s.key > 1 THEN (SELECT group_concat(p.value, '/' ORDER BY p.key) FROM json_each('[' || replace(json_quote(e.path), '/', '","') || ']') p WHERE p.key < s.key) END AS parent, 218 (SELECT group_concat(p.value, '/' ORDER BY p.key) FROM json_each('[' || replace(json_quote(e.path), '/', '","') || ']') p WHERE p.key <= s.key) AS page, 219 e.what, e.client, e.status, e.browser, e.ip, e.country 220 FROM events e, json_each('[' || replace(json_quote(e.path), '/', '","') || ']') s 221 WHERE s.key > 0 AND (s.value != '' OR s.key = 1) 222) h"#, 223 r#"CREATE VIEW pages AS 224SELECT parent, page, MAX(name) AS name, MAX(depth) AS depth, MAX(NOT landed) AS has_more, 225 SUM(client = 'browser' AND status < 400) AS views, 226 COUNT(DISTINCT CASE WHEN client = 'browser' AND browser != '' THEN browser END) AS browsers, 227 SUM(client = 'bot') AS by_bots, SUM(status >= 400) AS not_found, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms 228FROM page_hits WHERE what = 'view' GROUP BY parent, page"#, 229 r#"CREATE VIEW place_hits AS 230SELECT id AS event, at_ms, NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, what, client, status, browser, ip, path FROM events WHERE country != '' 231UNION ALL 232SELECT id, at_ms, country, country || ' / ' || COALESCE(NULLIF(region, ''), '?'), COALESCE(NULLIF(region, ''), '?'), 'region', 2, what, client, status, browser, ip, path FROM events WHERE country != '' 233UNION ALL 234SELECT id, at_ms, country || ' / ' || COALESCE(NULLIF(region, ''), '?'), country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?'), COALESCE(NULLIF(city, ''), '?'), 'city', 3, what, client, status, browser, ip, path FROM events WHERE country != '' 235UNION ALL 236SELECT id, at_ms, country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?'), country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?') || ' / ' || COALESCE(NULLIF(network, ''), '?'), COALESCE(NULLIF(network, ''), '?'), 'network', 4, what, client, status, browser, ip, path FROM events WHERE country != ''"#, 237 r#"CREATE VIEW places AS 238SELECT parent, place, MAX(name) AS name, MAX(kind) AS kind, MAX(depth) < 4 AS has_more, COUNT(*) AS events, 239 SUM(what = 'view' AND client = 'browser' AND status < 400) AS views, 240 COUNT(DISTINCT CASE WHEN client = 'browser' AND browser != '' THEN browser END) AS browsers, 241 COUNT(DISTINCT ip) AS addresses, SUM(client = 'bot') AS by_bots, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms 242FROM place_hits GROUP BY parent, place"#], 243 // `pages` and `places` again, a tenth of the cost. Read through 244 // `page_hits`, `pages` split every event's path, and one query read 245 // 2,523,507 rows of 57,000 events (the rows `json_each` makes are 246 // counted). Most events are the same page from the same browser, so 247 // they are counted first and each different one is split once: 327,936 248 // rows for the same answer, and 115,738 for `places` where it was 249 // 285,203 (measured on the live archive, 2026-10-03). `page_hits` and 250 // `place_hits` stay as they were: a condition on `at_ms` reaches the 251 // index through them, and would not through a window. 252 &["DROP VIEW pages", "DROP VIEW places", 253 r#"CREATE VIEW pages AS 254SELECT parent, page, MAX(CASE WHEN name = '' THEN '/' ELSE name END) AS name, MAX(depth) AS depth, MAX(NOT (page = path OR page || '/' = path)) AS has_more, 255 SUM(CASE WHEN client = 'browser' AND NOT missing THEN n ELSE 0 END) AS views, 256 COUNT(DISTINCT CASE WHEN client = 'browser' AND browser != '' THEN browser END) AS browsers, 257 SUM(CASE WHEN client = 'bot' THEN n ELSE 0 END) AS by_bots, SUM(CASE WHEN missing THEN n ELSE 0 END) AS not_found, 258 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms 259FROM ( 260 SELECT g.path, g.client, g.missing, g.browser, g.n, g.first_ms, g.last_ms, s.key AS depth, s.value AS name, 261 group_concat(s.value, '/') OVER w AS page, 262 CASE WHEN s.key > 1 THEN group_concat(s.value, '/') OVER (PARTITION BY g.path, g.client, g.missing, g.browser ORDER BY s.key ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) END AS parent 263 FROM (SELECT path, client, status >= 400 AS missing, browser, COUNT(*) AS n, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms FROM events WHERE what = 'view' GROUP BY path, client, missing, browser) g, 264 json_each('[' || replace(json_quote(g.path), '/', '","') || ']') s 265 WINDOW w AS (PARTITION BY g.path, g.client, g.missing, g.browser ORDER BY s.key ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) 266) WHERE depth > 0 AND (name != '' OR depth = 1) GROUP BY parent, page"#, 267 r#"CREATE VIEW places AS 268WITH g AS ( 269 SELECT country, COALESCE(NULLIF(region, ''), '?') AS region, COALESCE(NULLIF(city, ''), '?') AS city, COALESCE(NULLIF(network, ''), '?') AS network, 270 CASE WHEN client = 'browser' THEN browser ELSE '' END AS browser, ip, client = 'bot' AS bot, 271 COUNT(*) AS n, SUM(what = 'view' AND client = 'browser' AND status < 400) AS views, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms 272 FROM events WHERE country != '' GROUP BY 1, 2, 3, 4, 5, 6, 7 273), t AS ( 274 SELECT NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, browser, ip, bot, n, views, first_ms, last_ms FROM g 275 UNION ALL SELECT country, country || ' / ' || region, region, 'region', 2, browser, ip, bot, n, views, first_ms, last_ms FROM g 276 UNION ALL SELECT country || ' / ' || region, country || ' / ' || region || ' / ' || city, city, 'city', 3, browser, ip, bot, n, views, first_ms, last_ms FROM g 277 UNION ALL SELECT country || ' / ' || region || ' / ' || city, country || ' / ' || region || ' / ' || city || ' / ' || network, network, 'network', 4, browser, ip, bot, n, views, first_ms, last_ms FROM g 278) 279SELECT parent, place, MAX(name) AS name, MAX(kind) AS kind, MAX(depth) < 4 AS has_more, SUM(n) AS events, SUM(views) AS views, 280 COUNT(DISTINCT NULLIF(browser, '')) AS browsers, COUNT(DISTINCT ip) AS addresses, SUM(CASE WHEN bot THEN n ELSE 0 END) AS by_bots, 281 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms 282FROM t GROUP BY parent, place"#], 283 // What Cloudflare says of a request that was being thrown away: the 284 // region's code (the owner, 2026-10-03, wants a map of every level, and 285 // a region's outline is found by its code, not its name), the 286 // continent, the metro area, and what kind of bot it has verified the 287 // request as from. Then `place_hits` and `places` again, with a place's 288 // code and where it is: a region's code, and the middle of where its 289 // visitors were, which is where a city is drawn. 290 &["ALTER TABLE events ADD COLUMN region_code TEXT NOT NULL DEFAULT ''", "ALTER TABLE events ADD COLUMN continent TEXT NOT NULL DEFAULT ''", 291 "ALTER TABLE events ADD COLUMN metro TEXT NOT NULL DEFAULT ''", "ALTER TABLE events ADD COLUMN bot TEXT NOT NULL DEFAULT ''", 292 "DROP VIEW places", "DROP VIEW place_hits", 293 r#"CREATE VIEW place_hits AS 294SELECT id AS event, at_ms, NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, country AS code, latitude, longitude, what, client, status, browser, ip, path FROM events WHERE country != '' 295UNION ALL 296SELECT id, at_ms, country, country || ' / ' || COALESCE(NULLIF(region, ''), '?'), COALESCE(NULLIF(region, ''), '?'), 'region', 2, region_code, latitude, longitude, what, client, status, browser, ip, path FROM events WHERE country != '' 297UNION ALL 298SELECT id, at_ms, country || ' / ' || COALESCE(NULLIF(region, ''), '?'), country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?'), COALESCE(NULLIF(city, ''), '?'), 'city', 3, '', latitude, longitude, what, client, status, browser, ip, path FROM events WHERE country != '' 299UNION ALL 300SELECT id, at_ms, country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?'), country || ' / ' || COALESCE(NULLIF(region, ''), '?') || ' / ' || COALESCE(NULLIF(city, ''), '?') || ' / ' || COALESCE(NULLIF(network, ''), '?'), COALESCE(NULLIF(network, ''), '?'), 'network', 4, '', latitude, longitude, what, client, status, browser, ip, path FROM events WHERE country != ''"#, 301 r#"CREATE VIEW places AS 302WITH g AS ( 303 SELECT country, COALESCE(NULLIF(region, ''), '?') AS region, COALESCE(NULLIF(city, ''), '?') AS city, COALESCE(NULLIF(network, ''), '?') AS network, 304 CASE WHEN client = 'browser' THEN browser ELSE '' END AS browser, ip, client = 'bot' AS bot, MAX(region_code) AS region_code, 305 COUNT(*) AS n, SUM(what = 'view' AND client = 'browser' AND status < 400) AS views, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms, 306 SUM(latitude) AS latitudes, SUM(longitude) AS longitudes, COUNT(latitude) AS located 307 FROM events WHERE country != '' GROUP BY 1, 2, 3, 4, 5, 6, 7 308), t AS ( 309 SELECT NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, country AS code, browser, ip, bot, n, views, first_ms, last_ms, latitudes, longitudes, located FROM g 310 UNION ALL SELECT country, country || ' / ' || region, region, 'region', 2, region_code, browser, ip, bot, n, views, first_ms, last_ms, latitudes, longitudes, located FROM g 311 UNION ALL SELECT country || ' / ' || region, country || ' / ' || region || ' / ' || city, city, 'city', 3, '', browser, ip, bot, n, views, first_ms, last_ms, latitudes, longitudes, located FROM g 312 UNION ALL SELECT country || ' / ' || region || ' / ' || city, country || ' / ' || region || ' / ' || city || ' / ' || network, network, 'network', 4, '', browser, ip, bot, n, views, first_ms, last_ms, latitudes, longitudes, located FROM g 313) 314SELECT parent, place, MAX(name) AS name, MAX(kind) AS kind, MAX(depth) < 4 AS has_more, SUM(n) AS events, SUM(views) AS views, 315 COUNT(DISTINCT NULLIF(browser, '')) AS browsers, COUNT(DISTINCT ip) AS addresses, SUM(CASE WHEN bot THEN n ELSE 0 END) AS by_bots, 316 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms, MAX(code) AS code, 317 ROUND(SUM(latitudes) / SUM(located), 3) AS latitude, ROUND(SUM(longitudes) / SUM(located), 3) AS longitude 318FROM t GROUP BY parent, place"#], 319 // The owner's backend asked `events` the same questions on every page, 320 // from SQL written in Rust (the owner, 2026-10-04: port them into the 321 // database "as composable functions"). SQLite has no functions of our 322 // own and no stored views, so each question is a view with a `day` to 323 // ask it of, and a day that is over is copied into a table beside it 324 // once (`Shelf::finish`): never worked out again, since it cannot change. 325 // 326 // `events.day` is worked out from `at_ms` and indexed, so a view asked 327 // of one day reads that day's events and no others. `hits` is every 328 // event with what it counts as (a page viewed, an ask, a visit begun), 329 // said once. `hits_unfinished` is the events of the days not copied 330 // yet, and is what every `<name>_live` view reads. `<name>_finished` 331 // holds the days that are over, `finished` says through which day, and 332 // `<name>` is the two together: the one to ask. A `_by_day` view is a 333 // number for each label of each day. Different browsers on two days do 334 // not add up, so beside each such number is a view of who they were 335 // (`country_visitors`), to count a stretch of days from. 336 &[ 337 r#"ALTER TABLE events ADD COLUMN day INTEGER GENERATED ALWAYS AS (CAST(at_ms / 86400000 AS INTEGER)) VIRTUAL"#, 338 r#"CREATE INDEX events_day ON events (day)"#, 339 r#"CREATE TABLE finished (name TEXT PRIMARY KEY, through INTEGER NOT NULL) WITHOUT ROWID"#, 340 r#"CREATE VIEW hits AS 341SELECT id AS event, at_ms, day, CAST(at_ms / 3600000 AS INTEGER) AS hour, what, detail, path, input, client, status, browser, ip, 342 CASE WHEN browser = '' THEN ip ELSE browser END AS visitor, 343 (what = 'view' AND client = 'browser' AND status < 400) AS viewed, daily AS visited, session AS began, first AS arrived, 344 (what = 'gate') AS typed, (what = 'answer') AS asked, (what = 'answer' AND detail = 'answered') AS answered, (what = 'vote') AS voted, (what = 'fetch') AS fetched, 345 CASE WHEN what = 'read' THEN took_ms ELSE 0 END AS read_ms, 346 CASE WHEN what = 'view' AND referrer != '' AND referrer NOT LIKE 'https://lmjtfy.fun/%' THEN CASE WHEN instr(substr(referrer, instr(referrer, '://') + 3), '/') > 0 THEN substr(substr(referrer, instr(referrer, '://') + 3), 1, instr(substr(referrer, instr(referrer, '://') + 3), '/') - 1) ELSE substr(referrer, instr(referrer, '://') + 3) END ELSE '' END AS came_from, 347 country, region, city, network, family || ' on ' || os AS agent_kind 348FROM events"#, 349 r#"CREATE VIEW hits_unfinished AS 350SELECT * FROM hits WHERE day > (SELECT MIN(through) FROM finished)"#, 351 r#"CREATE VIEW hours_live AS 352SELECT day, hour, SUM(viewed) AS views, SUM(visited) AS visitors, SUM(began) AS sessions, SUM(arrived) AS new, SUM(asked) AS asks, SUM(answered) AS answered, SUM(voted) AS votes, SUM(fetched) AS fetches, SUM(read_ms) AS read_ms FROM hits_unfinished GROUP BY day, hour"#, 353 r#"CREATE TABLE hours_finished (day INTEGER NOT NULL, hour INTEGER NOT NULL, views, visitors, sessions, new, asks, answered, votes, fetches, read_ms, PRIMARY KEY (day, hour)) WITHOUT ROWID"#, 354 r#"INSERT INTO finished (name, through) VALUES ('hours', -1)"#, 355 r#"CREATE VIEW hours AS 356SELECT day, hour, views, visitors, sessions, new, asks, answered, votes, fetches, read_ms FROM hours_finished UNION ALL SELECT day, hour, views, visitors, sessions, new, asks, answered, votes, fetches, read_ms FROM hours_live WHERE day > (SELECT through FROM finished WHERE name = 'hours')"#, 357 r#"CREATE VIEW browser_days_live AS 358SELECT day, browser, MAX(what = 'view') AS viewed, MAX(typed) AS typed, MAX(asked) AS asked, MAX(voted) AS voted, MAX(arrived) AS first, MAX(client = 'browser') AS seen FROM hits_unfinished WHERE browser != '' GROUP BY day, browser"#, 359 r#"CREATE TABLE browser_days_finished (day INTEGER NOT NULL, browser TEXT NOT NULL, viewed, typed, asked, voted, first, seen, PRIMARY KEY (day, browser)) WITHOUT ROWID"#, 360 r#"INSERT INTO finished (name, through) VALUES ('browser_days', -1)"#, 361 r#"CREATE VIEW browser_days AS 362SELECT day, browser, viewed, typed, asked, voted, first, seen FROM browser_days_finished UNION ALL SELECT day, browser, viewed, typed, asked, voted, first, seen FROM browser_days_live WHERE day > (SELECT through FROM finished WHERE name = 'browser_days')"#, 363 r#"CREATE VIEW outcomes_by_day_live AS 364SELECT day, what AS label, COUNT(*) AS n FROM hits_unfinished GROUP BY day, label"#, 365 r#"CREATE TABLE outcomes_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 366 r#"INSERT INTO finished (name, through) VALUES ('outcomes_by_day', -1)"#, 367 r#"CREATE VIEW outcomes_by_day AS 368SELECT day, label, n FROM outcomes_by_day_finished UNION ALL SELECT day, label, n FROM outcomes_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'outcomes_by_day')"#, 369 r#"CREATE VIEW ask_endings_by_day_live AS 370SELECT day, detail AS label, COUNT(*) AS n FROM hits_unfinished WHERE asked GROUP BY day, label"#, 371 r#"CREATE TABLE ask_endings_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 372 r#"INSERT INTO finished (name, through) VALUES ('ask_endings_by_day', -1)"#, 373 r#"CREATE VIEW ask_endings_by_day AS 374SELECT day, label, n FROM ask_endings_by_day_finished UNION ALL SELECT day, label, n FROM ask_endings_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'ask_endings_by_day')"#, 375 r#"CREATE VIEW pages_by_day_live AS 376SELECT day, path AS label, COUNT(*) AS n FROM hits_unfinished WHERE viewed GROUP BY day, label"#, 377 r#"CREATE TABLE pages_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 378 r#"INSERT INTO finished (name, through) VALUES ('pages_by_day', -1)"#, 379 r#"CREATE VIEW pages_by_day AS 380SELECT day, label, n FROM pages_by_day_finished UNION ALL SELECT day, label, n FROM pages_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'pages_by_day')"#, 381 r#"CREATE VIEW questions_by_day_live AS 382SELECT day, input AS label, COUNT(*) AS n FROM hits_unfinished WHERE asked AND input != '' GROUP BY day, label"#, 383 r#"CREATE TABLE questions_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 384 r#"INSERT INTO finished (name, through) VALUES ('questions_by_day', -1)"#, 385 r#"CREATE VIEW questions_by_day AS 386SELECT day, label, n FROM questions_by_day_finished UNION ALL SELECT day, label, n FROM questions_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'questions_by_day')"#, 387 r#"CREATE VIEW referrers_by_day_live AS 388SELECT day, came_from AS label, COUNT(*) AS n FROM hits_unfinished WHERE came_from != '' GROUP BY day, label"#, 389 r#"CREATE TABLE referrers_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 390 r#"INSERT INTO finished (name, through) VALUES ('referrers_by_day', -1)"#, 391 r#"CREATE VIEW referrers_by_day AS 392SELECT day, label, n FROM referrers_by_day_finished UNION ALL SELECT day, label, n FROM referrers_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'referrers_by_day')"#, 393 r#"CREATE VIEW countries_by_day_live AS 394SELECT day, country AS label, COUNT(DISTINCT visitor) AS n FROM hits_unfinished WHERE client = 'browser' GROUP BY day, label"#, 395 r#"CREATE TABLE countries_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 396 r#"INSERT INTO finished (name, through) VALUES ('countries_by_day', -1)"#, 397 r#"CREATE VIEW countries_by_day AS 398SELECT day, label, n FROM countries_by_day_finished UNION ALL SELECT day, label, n FROM countries_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'countries_by_day')"#, 399 r#"CREATE VIEW country_visitors_live AS 400SELECT day, country AS label, visitor AS who FROM hits_unfinished WHERE client = 'browser' GROUP BY day, label, who"#, 401 r#"CREATE TABLE country_visitors_finished (day INTEGER NOT NULL, label NOT NULL, who NOT NULL, PRIMARY KEY (day, label, who)) WITHOUT ROWID"#, 402 r#"INSERT INTO finished (name, through) VALUES ('country_visitors', -1)"#, 403 r#"CREATE VIEW country_visitors AS 404SELECT day, label, who FROM country_visitors_finished UNION ALL SELECT day, label, who FROM country_visitors_live WHERE day > (SELECT through FROM finished WHERE name = 'country_visitors')"#, 405 r#"CREATE VIEW cities_by_day_live AS 406SELECT day, city || ', ' || country AS label, COUNT(DISTINCT visitor) AS n FROM hits_unfinished WHERE client = 'browser' AND city != '' GROUP BY day, label"#, 407 r#"CREATE TABLE cities_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 408 r#"INSERT INTO finished (name, through) VALUES ('cities_by_day', -1)"#, 409 r#"CREATE VIEW cities_by_day AS 410SELECT day, label, n FROM cities_by_day_finished UNION ALL SELECT day, label, n FROM cities_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'cities_by_day')"#, 411 r#"CREATE VIEW city_visitors_live AS 412SELECT day, city || ', ' || country AS label, visitor AS who FROM hits_unfinished WHERE client = 'browser' AND city != '' GROUP BY day, label, who"#, 413 r#"CREATE TABLE city_visitors_finished (day INTEGER NOT NULL, label NOT NULL, who NOT NULL, PRIMARY KEY (day, label, who)) WITHOUT ROWID"#, 414 r#"INSERT INTO finished (name, through) VALUES ('city_visitors', -1)"#, 415 r#"CREATE VIEW city_visitors AS 416SELECT day, label, who FROM city_visitors_finished UNION ALL SELECT day, label, who FROM city_visitors_live WHERE day > (SELECT through FROM finished WHERE name = 'city_visitors')"#, 417 r#"CREATE VIEW networks_by_day_live AS 418SELECT day, network AS label, COUNT(DISTINCT visitor) AS n FROM hits_unfinished WHERE client = 'browser' GROUP BY day, label"#, 419 r#"CREATE TABLE networks_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 420 r#"INSERT INTO finished (name, through) VALUES ('networks_by_day', -1)"#, 421 r#"CREATE VIEW networks_by_day AS 422SELECT day, label, n FROM networks_by_day_finished UNION ALL SELECT day, label, n FROM networks_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'networks_by_day')"#, 423 r#"CREATE VIEW network_visitors_live AS 424SELECT day, network AS label, visitor AS who FROM hits_unfinished WHERE client = 'browser' GROUP BY day, label, who"#, 425 r#"CREATE TABLE network_visitors_finished (day INTEGER NOT NULL, label NOT NULL, who NOT NULL, PRIMARY KEY (day, label, who)) WITHOUT ROWID"#, 426 r#"INSERT INTO finished (name, through) VALUES ('network_visitors', -1)"#, 427 r#"CREATE VIEW network_visitors AS 428SELECT day, label, who FROM network_visitors_finished UNION ALL SELECT day, label, who FROM network_visitors_live WHERE day > (SELECT through FROM finished WHERE name = 'network_visitors')"#, 429 r#"CREATE VIEW agents_by_day_live AS 430SELECT day, agent_kind AS label, COUNT(DISTINCT browser) AS n FROM hits_unfinished WHERE client = 'browser' AND browser != '' GROUP BY day, label"#, 431 r#"CREATE TABLE agents_by_day_finished (day INTEGER NOT NULL, label NOT NULL, n, PRIMARY KEY (day, label)) WITHOUT ROWID"#, 432 r#"INSERT INTO finished (name, through) VALUES ('agents_by_day', -1)"#, 433 r#"CREATE VIEW agents_by_day AS 434SELECT day, label, n FROM agents_by_day_finished UNION ALL SELECT day, label, n FROM agents_by_day_live WHERE day > (SELECT through FROM finished WHERE name = 'agents_by_day')"#, 435 r#"CREATE VIEW agent_browsers_live AS 436SELECT day, agent_kind AS label, browser AS who FROM hits_unfinished WHERE client = 'browser' AND browser != '' GROUP BY day, label, who"#, 437 r#"CREATE TABLE agent_browsers_finished (day INTEGER NOT NULL, label NOT NULL, who NOT NULL, PRIMARY KEY (day, label, who)) WITHOUT ROWID"#, 438 r#"INSERT INTO finished (name, through) VALUES ('agent_browsers', -1)"#, 439 r#"CREATE VIEW agent_browsers AS 440SELECT day, label, who FROM agent_browsers_finished UNION ALL SELECT day, label, who FROM agent_browsers_live WHERE day > (SELECT through FROM finished WHERE name = 'agent_browsers')"#, 441 r#"CREATE VIEW days AS 442SELECT day, SUM(views) AS views, SUM(visitors) AS visitors, SUM(sessions) AS sessions, SUM(new) AS new, SUM(asks) AS asks, SUM(answered) AS answered, SUM(votes) AS votes, SUM(fetches) AS fetches, SUM(read_ms) AS read_ms FROM hours GROUP BY day"#, 443 r#"CREATE VIEW today_by_hour AS 444SELECT * FROM hours WHERE day = CAST(unixepoch() / 86400 AS INTEGER)"#, 445 ], 446 // `pages` and `places`, the two trees, by the day as well. They were 447 // still a pass over every event at each click (118,000 rows for a 448 // level of `places` on 2026-10-04, and more each day). A tree's level 449 // for a day is `page_days` and `place_days`; who was there, to count 450 // once over many days, is `page_visitors`, `place_visitors` and 451 // `place_addresses`. `pages` and `places` are those added up, with the 452 // columns they had. A `_finished` table's key cannot be NULL, so the 453 // top of a tree is kept as '' and shown as NULL, which is what 454 // `WHERE parent IS NULL` asks for. `hits` gains what a place needs. 455 // The new views gave the same rows as the old on a copy of the live 456 // archive, both read live and after a day was finished. 457 &[ 458 r#"DROP VIEW pages"#, 459 r#"DROP VIEW places"#, 460 r#"DROP VIEW hits"#, 461 r#"CREATE VIEW hits AS 462SELECT id AS event, at_ms, day, CAST(at_ms / 3600000 AS INTEGER) AS hour, what, detail, path, input, client, status, browser, ip, 463 CASE WHEN browser = '' THEN ip ELSE browser END AS visitor, 464 (what = 'view' AND client = 'browser' AND status < 400) AS viewed, daily AS visited, session AS began, first AS arrived, 465 (what = 'gate') AS typed, (what = 'answer') AS asked, (what = 'answer' AND detail = 'answered') AS answered, (what = 'vote') AS voted, (what = 'fetch') AS fetched, 466 CASE WHEN what = 'read' THEN took_ms ELSE 0 END AS read_ms, 467 CASE WHEN what = 'view' AND referrer != '' AND referrer NOT LIKE 'https://lmjtfy.fun/%' THEN CASE WHEN instr(substr(referrer, instr(referrer, '://') + 3), '/') > 0 THEN substr(substr(referrer, instr(referrer, '://') + 3), 1, instr(substr(referrer, instr(referrer, '://') + 3), '/') - 1) ELSE substr(referrer, instr(referrer, '://') + 3) END ELSE '' END AS came_from, 468 country, region, city, network, family || ' on ' || os AS agent_kind, region_code, latitude, longitude 469FROM events"#, 470 r#"CREATE VIEW page_days_live AS 471SELECT day, COALESCE(parent, '') AS parent, page, MAX(CASE WHEN name = '' THEN '/' ELSE name END) AS name, MAX(depth) AS depth, MAX(NOT (page = path OR page || '/' = path)) AS has_more, 472 SUM(CASE WHEN client = 'browser' AND NOT missing THEN n ELSE 0 END) AS views, SUM(CASE WHEN client = 'bot' THEN n ELSE 0 END) AS by_bots, 473 SUM(CASE WHEN missing THEN n ELSE 0 END) AS not_found, MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms 474FROM ( 475 SELECT g.day, g.path, g.client, g.missing, g.n, g.first_ms, g.last_ms, s.key AS depth, s.value AS name, 476 group_concat(s.value, '/') OVER w AS page, 477 CASE WHEN s.key > 1 THEN group_concat(s.value, '/') OVER (PARTITION BY g.day, g.path, g.client, g.missing ORDER BY s.key ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) END AS parent 478 FROM (SELECT day, path, client, status >= 400 AS missing, COUNT(*) AS n, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms FROM hits_unfinished WHERE what = 'view' GROUP BY day, path, client, missing) g, 479 json_each('[' || replace(json_quote(g.path), '/', '","') || ']') s 480 WINDOW w AS (PARTITION BY g.day, g.path, g.client, g.missing ORDER BY s.key ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) 481) WHERE depth > 0 AND (name != '' OR depth = 1) GROUP BY day, 2, page"#, 482 r#"CREATE TABLE page_days_finished (day INTEGER NOT NULL, parent TEXT NOT NULL, page TEXT NOT NULL, name, depth, has_more, views, by_bots, not_found, first_ms, last_ms, PRIMARY KEY (day, parent, page)) WITHOUT ROWID"#, 483 r#"INSERT INTO finished (name, through) VALUES ('page_days', -1)"#, 484 r#"CREATE VIEW page_days AS 485SELECT day, NULLIF(parent, '') AS parent, page, name, depth, has_more, views, by_bots, not_found, first_ms, last_ms FROM page_days_finished UNION ALL SELECT day, NULLIF(parent, '') AS parent, page, name, depth, has_more, views, by_bots, not_found, first_ms, last_ms FROM page_days_live WHERE day > (SELECT through FROM finished WHERE name = 'page_days')"#, 486 r#"CREATE VIEW page_visitors_live AS 487SELECT day, page, who FROM ( 488 SELECT g.day, g.who, s.key AS depth, s.value AS name, group_concat(s.value, '/') OVER (PARTITION BY g.day, g.path, g.who ORDER BY s.key ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS page 489 FROM (SELECT DISTINCT day, path, browser AS who FROM hits_unfinished WHERE what = 'view' AND client = 'browser' AND browser != '') g, 490 json_each('[' || replace(json_quote(g.path), '/', '","') || ']') s 491) WHERE depth > 0 AND (name != '' OR depth = 1) GROUP BY day, page, who"#, 492 r#"CREATE TABLE page_visitors_finished (day INTEGER NOT NULL, page TEXT NOT NULL, who TEXT NOT NULL, PRIMARY KEY (day, page, who)) WITHOUT ROWID"#, 493 r#"INSERT INTO finished (name, through) VALUES ('page_visitors', -1)"#, 494 r#"CREATE VIEW page_visitors AS 495SELECT day, page, who FROM page_visitors_finished UNION ALL SELECT day, page, who FROM page_visitors_live WHERE day > (SELECT through FROM finished WHERE name = 'page_visitors')"#, 496 r#"CREATE VIEW place_days_live AS 497WITH g AS ( 498 SELECT day, country, COALESCE(NULLIF(region, ''), '?') AS region, COALESCE(NULLIF(city, ''), '?') AS city, COALESCE(NULLIF(network, ''), '?') AS network, MAX(region_code) AS region_code, COUNT(*) AS n, SUM(viewed) AS views, SUM(client = 'bot') AS by_bots, MIN(at_ms) AS first_ms, MAX(at_ms) AS last_ms, 499 SUM(latitude) AS latitudes, SUM(longitude) AS longitudes, COUNT(latitude) AS located 500 FROM hits_unfinished WHERE country != '' GROUP BY 1, 2, 3, 4, 5 501), t AS ( 502 SELECT day, NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, country AS code, n, views, by_bots, first_ms, last_ms, latitudes, longitudes, located FROM g 503 UNION ALL SELECT day, country, country || ' / ' || region, region, 'region', 2, region_code, n, views, by_bots, first_ms, last_ms, latitudes, longitudes, located FROM g 504 UNION ALL SELECT day, country || ' / ' || region, country || ' / ' || region || ' / ' || city, city, 'city', 3, '', n, views, by_bots, first_ms, last_ms, latitudes, longitudes, located FROM g 505 UNION ALL SELECT day, country || ' / ' || region || ' / ' || city, country || ' / ' || region || ' / ' || city || ' / ' || network, network, 'network', 4, '', n, views, by_bots, first_ms, last_ms, latitudes, longitudes, located FROM g 506) 507SELECT day, COALESCE(parent, '') AS parent, place, MAX(name) AS name, MAX(kind) AS kind, MAX(depth) AS depth, SUM(n) AS events, SUM(views) AS views, SUM(by_bots) AS by_bots, 508 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms, MAX(code) AS code, SUM(latitudes) AS latitudes, SUM(longitudes) AS longitudes, SUM(located) AS located 509FROM t GROUP BY day, 2, place"#, 510 r#"CREATE TABLE place_days_finished (day INTEGER NOT NULL, parent TEXT NOT NULL, place TEXT NOT NULL, name, kind, depth, events, views, by_bots, first_ms, last_ms, code, latitudes, longitudes, located, PRIMARY KEY (day, parent, place)) WITHOUT ROWID"#, 511 r#"INSERT INTO finished (name, through) VALUES ('place_days', -1)"#, 512 r#"CREATE VIEW place_days AS 513SELECT day, NULLIF(parent, '') AS parent, place, name, kind, depth, events, views, by_bots, first_ms, last_ms, code, latitudes, longitudes, located FROM place_days_finished UNION ALL SELECT day, NULLIF(parent, '') AS parent, place, name, kind, depth, events, views, by_bots, first_ms, last_ms, code, latitudes, longitudes, located FROM place_days_live WHERE day > (SELECT through FROM finished WHERE name = 'place_days')"#, 514 r#"CREATE VIEW place_visitors_live AS 515WITH g AS ( 516 SELECT DISTINCT day, country, COALESCE(NULLIF(region, ''), '?') AS region, COALESCE(NULLIF(city, ''), '?') AS city, COALESCE(NULLIF(network, ''), '?') AS network, '' AS region_code, browser AS who FROM hits_unfinished WHERE country != '' AND client = 'browser' AND browser != '' 517), t AS ( 518 SELECT day, NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, country AS code, who FROM g 519 UNION ALL SELECT day, country, country || ' / ' || region, region, 'region', 2, region_code, who FROM g 520 UNION ALL SELECT day, country || ' / ' || region, country || ' / ' || region || ' / ' || city, city, 'city', 3, '', who FROM g 521 UNION ALL SELECT day, country || ' / ' || region || ' / ' || city, country || ' / ' || region || ' / ' || city || ' / ' || network, network, 'network', 4, '', who FROM g 522) 523SELECT day, place, who FROM t GROUP BY day, place, who"#, 524 r#"CREATE TABLE place_visitors_finished (day INTEGER NOT NULL, place TEXT NOT NULL, who TEXT NOT NULL, PRIMARY KEY (day, place, who)) WITHOUT ROWID"#, 525 r#"INSERT INTO finished (name, through) VALUES ('place_visitors', -1)"#, 526 r#"CREATE VIEW place_visitors AS 527SELECT day, place, who FROM place_visitors_finished UNION ALL SELECT day, place, who FROM place_visitors_live WHERE day > (SELECT through FROM finished WHERE name = 'place_visitors')"#, 528 r#"CREATE VIEW place_addresses_live AS 529WITH g AS ( 530 SELECT DISTINCT day, country, COALESCE(NULLIF(region, ''), '?') AS region, COALESCE(NULLIF(city, ''), '?') AS city, COALESCE(NULLIF(network, ''), '?') AS network, '' AS region_code, ip AS who FROM hits_unfinished WHERE country != '' 531), t AS ( 532 SELECT day, NULL AS parent, country AS place, country AS name, 'country' AS kind, 1 AS depth, country AS code, who FROM g 533 UNION ALL SELECT day, country, country || ' / ' || region, region, 'region', 2, region_code, who FROM g 534 UNION ALL SELECT day, country || ' / ' || region, country || ' / ' || region || ' / ' || city, city, 'city', 3, '', who FROM g 535 UNION ALL SELECT day, country || ' / ' || region || ' / ' || city, country || ' / ' || region || ' / ' || city || ' / ' || network, network, 'network', 4, '', who FROM g 536) 537SELECT day, place, who FROM t GROUP BY day, place, who"#, 538 r#"CREATE TABLE place_addresses_finished (day INTEGER NOT NULL, place TEXT NOT NULL, who TEXT NOT NULL, PRIMARY KEY (day, place, who)) WITHOUT ROWID"#, 539 r#"INSERT INTO finished (name, through) VALUES ('place_addresses', -1)"#, 540 r#"CREATE VIEW place_addresses AS 541SELECT day, place, who FROM place_addresses_finished UNION ALL SELECT day, place, who FROM place_addresses_live WHERE day > (SELECT through FROM finished WHERE name = 'place_addresses')"#, 542 r#"CREATE VIEW pages AS 543SELECT d.parent, d.page, d.name, d.depth, d.has_more, d.views, COALESCE(v.browsers, 0) AS browsers, d.by_bots, d.not_found, d.first_ms, d.last_ms 544FROM (SELECT parent, page, MAX(name) AS name, MAX(depth) AS depth, MAX(has_more) AS has_more, SUM(views) AS views, SUM(by_bots) AS by_bots, SUM(not_found) AS not_found, 545 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms FROM page_days GROUP BY parent, page) d 546LEFT JOIN (SELECT page, COUNT(DISTINCT who) AS browsers FROM page_visitors GROUP BY page) v ON v.page = d.page"#, 547 r#"CREATE VIEW places AS 548SELECT d.parent, d.place, d.name, d.kind, d.depth < 4 AS has_more, d.events, d.views, COALESCE(v.browsers, 0) AS browsers, COALESCE(a.addresses, 0) AS addresses, d.by_bots, 549 d.first_ms, d.last_ms, d.code, ROUND(d.latitudes / d.located, 3) AS latitude, ROUND(d.longitudes / d.located, 3) AS longitude 550FROM (SELECT parent, place, MAX(name) AS name, MAX(kind) AS kind, MAX(depth) AS depth, SUM(events) AS events, SUM(views) AS views, SUM(by_bots) AS by_bots, 551 MIN(first_ms) AS first_ms, MAX(last_ms) AS last_ms, MAX(code) AS code, SUM(latitudes) AS latitudes, SUM(longitudes) AS longitudes, SUM(located) AS located 552 FROM place_days GROUP BY parent, place) d 553LEFT JOIN (SELECT place, COUNT(DISTINCT who) AS browsers FROM place_visitors GROUP BY place) v ON v.place = d.place 554LEFT JOIN (SELECT place, COUNT(DISTINCT who) AS addresses FROM place_addresses GROUP BY place) a ON a.place = d.place"#, 555 ], 556 // What is kept is not changed (the owner, 2026-10-02: every response 557 // is kept; 2026-10-03: nothing is dropped). That was a rule for 558 // whoever wrote the next statement. Now the archive refuses: a row of 559 // `versions` or `events` can be added and nothing else. A migration 560 // that must correct one drops the trigger first, in the open. 561 &[ 562 "CREATE TRIGGER versions_are_not_changed BEFORE UPDATE ON versions BEGIN SELECT RAISE(ABORT, 'a kept response is never changed'); END", 563 "CREATE TRIGGER versions_are_not_deleted BEFORE DELETE ON versions BEGIN SELECT RAISE(ABORT, 'a kept response is never deleted'); END", 564 "CREATE TRIGGER events_are_not_changed BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'an event is never changed'); END", 565 "CREATE TRIGGER events_are_not_deleted BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'an event is never deleted'); END", 566 ], 567 // When a step ran, and the build that ran it. The way back from a step 568 // that went wrong is the archive as it was just before it 569 // (`Admin::Restore`), and until now nothing said when that was. Steps 570 // before this one have no time. And `asked_lately` goes: `asked_feed` 571 // (step 11) is the same order with the tie broken, so nothing has read 572 // it since, and every question asked was written to it. 573 &["ALTER TABLE migrated ADD COLUMN at_ms REAL", "ALTER TABLE migrated ADD COLUMN build TEXT", "DROP INDEX asked_lately"], 574 // A comment on a vote (the owner, 2026-10-05: thumbs up or down opens a 575 // box, "Comments?"). It is a table of its own, not a column of `ratings` 576 // and not an `events` row, because of what each is for. `ratings` is the 577 // current vote and is deleted when a vote is taken back, so a column 578 // there would lose the words with the vote, and "nothing is dropped" 579 // (2026-10-03). `events` is the request log, and its columns are what 580 // `Event` says: free text from the public in `detail` would run through 581 // every `hits` view and the owner's copies of it. So `comments` is 582 // append-only like `events` (triggers below), a row per Save, tied to 583 // the vote by the same key as `ratings` (`input`, `answer`, `who`) with 584 // the vote as it was then (`vote`), so a vote changed or taken back 585 // later does not change what the comment was said of. `vote_comments` is 586 // every comment beside the vote as it is now; `votes_commented` is every 587 // vote now standing beside its latest comment, if it has one. Neither is 588 // asked by the day, so neither has a `_live`/`_finished` pair. The text 589 // is the public's own: the admin must show it as text, never as markup. 590 &[ 591 "CREATE TABLE comments (id INTEGER PRIMARY KEY, at_ms REAL NOT NULL, input TEXT NOT NULL, answer TEXT NOT NULL, who TEXT NOT NULL, browser TEXT NOT NULL, vote INTEGER NOT NULL, comment TEXT NOT NULL)", 592 "CREATE INDEX comments_vote ON comments (input, answer, who, id)", 593 "CREATE INDEX comments_lately ON comments (at_ms DESC)", 594 "CREATE TRIGGER comments_are_not_changed BEFORE UPDATE ON comments BEGIN SELECT RAISE(ABORT, 'a comment is never changed'); END", 595 "CREATE TRIGGER comments_are_not_deleted BEFORE DELETE ON comments BEGIN SELECT RAISE(ABORT, 'a comment is never deleted'); END", 596 r#"CREATE VIEW vote_comments AS 597SELECT c.id, c.at_ms, CAST(c.at_ms / 86400000 AS INTEGER) AS day, c.input, c.browser, 598 CASE c.vote WHEN 1 THEN 'up' ELSE 'down' END AS vote_then, 599 CASE r.vote WHEN 1 THEN 'up' WHEN -1 THEN 'down' END AS vote_now, 600 c.id = (SELECT MAX(l.id) FROM comments l WHERE l.input = c.input AND l.answer = c.answer AND l.who = c.who) AS latest, 601 c.comment 602FROM comments c LEFT JOIN ratings r ON r.input = c.input AND r.answer = c.answer AND r.who = c.who"#, 603 r#"CREATE VIEW votes_commented AS 604SELECT r.input, r.browser, CASE r.vote WHEN 1 THEN 'up' ELSE 'down' END AS vote, c.at_ms AS commented_ms, c.comment 605FROM ratings r LEFT JOIN comments c ON c.id = (SELECT MAX(l.id) FROM comments l WHERE l.input = r.input AND l.answer = r.answer AND l.who = r.who)"#, 606 ], 607 // Questions Jev cannot take, so a link to one can unfurl as "choose an 608 // LLM" and not as "Let me Jev that for you". Apart from `asked` because 609 // nothing was answered: they are not counted, listed or toasted, and 610 // `asked` is all of those. Only a question, and when; who asked is in 611 // `events`. 612 &["CREATE TABLE declined (input TEXT PRIMARY KEY, declined_ms REAL NOT NULL, times INTEGER NOT NULL DEFAULT 1)"], 613 // Votes and comments on a question Jev declined. A vote is on an 614 // answer, keyed by the hash of the answers kept (`ratings.answer`); a 615 // declined question has no answer, so its key is the word `declined`, 616 // which no hash can equal, and it is tied to the `declined` row by `input` 617 // (`ratings.input = declined.input AND ratings.answer = 'declined'`). The 618 // key stays one column, so every row is still readable and unique as it 619 // was. `about` says which kind a row is, worked out from `answer` and 620 // not written, so the two cannot disagree. The thumbs mean otherwise 621 // there: up is "right to decline", down is "Jev could have answered". 622 // The two views carry `about`. 623 &[ 624 "ALTER TABLE ratings ADD COLUMN about TEXT GENERATED ALWAYS AS (CASE WHEN answer = 'declined' THEN 'declined' ELSE 'answered' END) VIRTUAL", 625 "ALTER TABLE comments ADD COLUMN about TEXT GENERATED ALWAYS AS (CASE WHEN answer = 'declined' THEN 'declined' ELSE 'answered' END) VIRTUAL", 626 "DROP VIEW vote_comments", 627 "DROP VIEW votes_commented", 628 r#"CREATE VIEW vote_comments AS 629SELECT c.id, c.at_ms, CAST(c.at_ms / 86400000 AS INTEGER) AS day, c.input, c.browser, c.about, 630 CASE c.vote WHEN 1 THEN 'up' ELSE 'down' END AS vote_then, 631 CASE r.vote WHEN 1 THEN 'up' WHEN -1 THEN 'down' END AS vote_now, 632 c.id = (SELECT MAX(l.id) FROM comments l WHERE l.input = c.input AND l.answer = c.answer AND l.who = c.who) AS latest, 633 c.comment 634FROM comments c LEFT JOIN ratings r ON r.input = c.input AND r.answer = c.answer AND r.who = c.who"#, 635 r#"CREATE VIEW votes_commented AS 636SELECT r.input, r.browser, r.about, CASE r.vote WHEN 1 THEN 'up' ELSE 'down' END AS vote, c.at_ms AS commented_ms, c.comment 637FROM ratings r LEFT JOIN comments c ON c.id = (SELECT MAX(l.id) FROM comments l WHERE l.input = r.input AND l.answer = r.answer AND l.who = r.who)"#, 638 ], 639];
The key of a vote on a question Jev declined, in the place of the hash of the answers a vote on an answer has (MIGRATIONS step 21). No hash is this.
643pub const DECLINED: &str = "declined";
The step (from 1) from which migrated says when a step ran.
646const TIMED: usize = 18;
The views whose finished days are kept: each has a <name>_live view,
a <name>_finished table of the same columns, and a row of finished.
650const FINISHED: [&str; 20] = [ 651 "hours", "browser_days", "outcomes_by_day", "ask_endings_by_day", "pages_by_day", "questions_by_day", "referrers_by_day", 652 "countries_by_day", "country_visitors", "cities_by_day", "city_visitors", "networks_by_day", "network_visitors", "agents_by_day", "agent_browsers", 653 "page_days", "page_visitors", "place_days", "place_visitors", "place_addresses", 654];
The statements that copy day of the view name into its table, each
with the day as its one value. Whatever was copied of the day is put
aside first, so a copy that was cut short is done again whole. Neither
touches a day finished already has: its events are no longer in
<name>_live, and putting it aside would lose it.
661fn finish_sql(name: &str) -> [String; 2] { 662 let unfinished = format!("?1 > (SELECT through FROM finished WHERE name = '{name}')"); 663 [format!("DELETE FROM {name}_finished WHERE day = ?1 AND {unfinished}"), format!("INSERT INTO {name}_finished SELECT * FROM {name}_live WHERE day = ?1 AND {unfinished}")] 664}
666const DAY_MS: f64 = 86_400_000.0;
The step (from 1) that gave askers and ratings their browser, after
which the rows already there are named from events, once.
670const ASKERS_NAMED: usize = 10;
SQLite's numbers arrive as JS numbers, so every number is read as an f64.
682#[derive(Deserialize)] 683struct AskedRow { 684 input: String, 685 answers: String, 686 asked_ms: f64, 687 times: f64, 688} 689 690impl From<AskedRow> for Entry { 691 fn from(row: AskedRow) -> Self { 692 Entry { 693 input: row.input, 694 answers: ::archive::stored(&row.answers), 695 asked_ms: row.asked_ms, 696 times: row.times as u32, 697 } 698 } 699} 700 701#[derive(Deserialize)] 702struct Number { 703 n: f64, 704} 705 706#[derive(Deserialize)] 707struct Tally { 708 questions: f64, 709 asks: f64, 710 no_llm: f64, 711}
The repository whose clones and pulls the pages show. jevcrates is counted too, but a clone of lmjtfy fetches it, so it is not shown twice.
715const CODE: &str = "lmjtfy.git";
A call on the wire, which every identical ask waits on.
722type Running = Shared<LocalBoxFuture<'static, Called>>;
What a call needs after the request that started it has gone: shared, so the call can outlive that request.
What the home page shows, kept from the last time it was read until
something it is made of changes (changed). Every home page asks
for it, and its numbers are a pass over every question.
733 home: RefCell<Option<Home>>,
Every question the feed may show, for suggesting as a visitor types:
read once, and again only after a question is asked or moderated
(relisted). A suggestion is wanted on every pause in the typing,
and a pass over asked each time would be most of the rows read.
738 listed: RefCell<Option<::archive::Listed>>,
The last day known to be kept (days since 1970), so that only the first event of a new day looks.
744impl Shelf {
How many responses are kept for a request.
746 fn versions(&self, sent_to: &str, request: &str) -> worker::Result<u32> { 747 let rows: Vec<Number> = self 748 .sql 749 .exec("SELECT COUNT(*) AS n FROM versions WHERE sent_to = ? AND request = ?", vec![sent_to.into(), request.into()])? 750 .to_array()?; 751 Ok(rows.first().map_or(0, |row| row.n as u32)) 752 }
A kept response: version, or the newest.
755 fn kept(&self, sent_to: &str, request: &str, version: Option<u32>) -> worker::Result<Option<Record>> { 756 let versions = self.versions(sent_to, request)?; 757 let Some(version) = version.filter(|version| (1..=versions).contains(version)).or((versions > 0).then_some(versions)) else { 758 return Ok(None); 759 }; 760 let rows: Vec<CallRow> = self 761 .sql 762 .exec( 763 "SELECT response, request_id, attempts, took_ms, answered_ms FROM versions WHERE sent_to = ? AND request = ? AND version = ?", 764 vec![sent_to.into(), request.into(), i64::from(version).into()], 765 )? 766 .to_array()?; 767 Ok(rows.into_iter().next().map(|row| Record { 768 response: row.response, 769 request_id: row.request_id, 770 attempts: row.attempts as u32, 771 took_ms: row.took_ms, 772 answered_ms: row.answered_ms, 773 sent_now: false, 774 version, 775 versions, 776 })) 777 }
Keeps a response as the request's newest version, and says which it is. A plain INSERT: two calls numbering the same version would mean the archive sent twice what it meant to send once, and that should fail here, not be papered over.
783 fn keep(&self, sent_to: &str, request: &str, record: &Record) -> worker::Result<u32> { 784 let version = self.versions(sent_to, request)? + 1; 785 self.sql.exec( 786 "INSERT INTO versions (sent_to, request, version, response, request_id, attempts, took_ms, answered_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 787 vec![ 788 sent_to.into(), 789 request.into(), 790 i64::from(version).into(), 791 record.response.as_str().into(), 792 record.request_id.as_deref().map_or(worker::SqlStorageValue::Null, Into::into), 793 i64::from(record.attempts).into(), 794 record.took_ms.into(), 795 record.answered_ms.into(), 796 ], 797 )?; 798 Ok(version) 799 }
Runs the steps not yet run. Each is all or nothing (Past::atomically):
a step that fails leaves the archive as it was before it, to be
fixed and tried again, and not half made.
804 fn migrate(shelf: &Rc<Shelf>, past: &Past) -> Result<(), String> { 805 shelf.sql.exec("CREATE TABLE IF NOT EXISTS migrated (version INTEGER PRIMARY KEY)", None).map_err(|error| error.to_string())?; 806 let done = shelf.number("SELECT COUNT(*) AS n FROM migrated").map_err(|error| error.to_string())? as usize; 807 for (index, step) in MIGRATIONS.iter().enumerate().skip(done) { 808 let shelf = shelf.clone(); 809 past.atomically(move || { 810 for statement in *step { 811 shelf.sql.exec(statement, None)?; 812 } 813 shelf.sql.exec("INSERT INTO migrated (version) VALUES (?)", vec![(index as i64 + 1).into()])?; 814 if index + 1 >= TIMED { 815 shelf.sql.exec("UPDATE migrated SET at_ms = ?, build = ? WHERE version = ?", vec![js_sys::Date::now().into(), crate::BUILD.into(), (index as i64 + 1).into()])?; 816 } 817 if index + 1 == ASKERS_NAMED { 818 shelf.name_askers()?; 819 } 820 Ok(()) 821 }) 822 .map_err(|error| format!("step {}: {error}", index + 1))?; 823 } 824 Ok(()) 825 }
Keeps an event, whole, and says which row it is.
828 fn happened(&self, event: &::archive::Event, now_ms: f64) -> worker::Result<f64> { 829 let columns = ::archive::Event::COLUMNS; 830 let marks = vec!["?"; columns.len() + 1].join(", "); 831 let mut values: Vec<SqlStorageValue> = vec![now_ms.into()]; 832 values.extend(event.values().into_iter().map(|cell| match cell { 833 ::archive::event::Cell::Text(text) => text.into(), 834 ::archive::event::Cell::Number(number) => number.into(), 835 ::archive::event::Cell::Null => SqlStorageValue::Null, 836 })); 837 self.sql.exec(&format!("INSERT INTO events (at_ms, {}) VALUES ({marks})", columns.join(", ")), values)?; 838 let row = self.number("SELECT last_insert_rowid() AS n"); 839 // The first event of a day is when the day before is known to be 840 // over. Keeping it must not lose the event that noticed. 841 let yesterday = (now_ms / DAY_MS).floor() as i64 - 1; 842 if self.finished_to.get() < yesterday { 843 self.finished_to.set(yesterday); 844 if let Err(error) = self.finish(yesterday) { 845 console_error!("the days up to {yesterday} were not kept: {error}"); 846 } 847 } 848 row 849 }
Copies each finished day not yet kept, up to last, from every kept
view into its table. A day read here is not read from events for
that view again.
854 fn finish(&self, last: i64) -> worker::Result<()> { 855 let began: Vec<Number> = self.sql.exec("SELECT COALESCE(MIN(day), ?) AS n FROM events", vec![(last + 1).into()])?.to_array()?; 856 let began = began.first().map_or(last + 1, |row| row.n as i64); 857 for name in FINISHED { 858 let through: Vec<Number> = self.sql.exec("SELECT through AS n FROM finished WHERE name = ?", vec![name.into()])?.to_array()?; 859 let Some(through) = through.first().map(|row| row.n as i64) else { continue }; 860 let [put_aside, copy] = finish_sql(name); 861 for day in (through + 1).max(began)..=last { 862 self.sql.exec(&put_aside, vec![day.into()])?; 863 self.sql.exec(©, vec![day.into()])?; 864 self.sql.exec("UPDATE finished SET through = ? WHERE name = ?", vec![day.into(), name.into()])?; 865 } 866 } 867 Ok(()) 868 }
A page that connected (the event in row came) has gone: the same
event again, as left, with how long it stayed.
872 fn left(&self, came: f64, stayed_ms: f64, now_ms: f64) -> worker::Result<()> { 873 let columns = ::archive::Event::COLUMNS; 874 let copied: Vec<&str> = columns 875 .iter() 876 .map(|column| match *column { 877 "what" => "'left'", 878 "took_ms" => "?", 879 column => column, 880 }) 881 .collect(); 882 self.sql.exec( 883 &format!("INSERT INTO events (at_ms, {}) SELECT ?, {} FROM events WHERE id = ?", columns.join(", "), copied.join(", ")), 884 vec![now_ms.into(), stayed_ms.into(), came.into()], 885 )?; 886 Ok(()) 887 }
Whether the feed may show input: the owner's say if they have one,
Jev's otherwise.
The admin backend's requests (archive::Admin).
897 fn admin(&self, ask: ::archive::Admin) -> ::archive::Answered { 898 use ::archive::{Admin, Answered}; 899 let refused = |error: worker::Error| Answered::Refused(error.to_string()); 900 match ask { 901 Admin::Select { sql, .. } if !::archive::reads_only(&sql) => Answered::Refused("only one statement that reads".into()), 902 Admin::Select { sql, values } => { 903 let values: Vec<SqlStorageValue> = values 904 .into_iter() 905 .map(|value| match value { 906 serde_json::Value::Null => SqlStorageValue::Null, 907 serde_json::Value::Bool(flag) => i64::from(flag).into(), 908 serde_json::Value::Number(number) => number.as_f64().unwrap_or_default().into(), 909 serde_json::Value::String(text) => text.into(), 910 other => other.to_string().into(), 911 }) 912 .collect(); 913 let cursor = match self.sql.exec(&sql, values) { 914 Ok(cursor) => cursor, 915 Err(error) => return refused(error), 916 }; 917 let columns = cursor.column_names(); 918 let mut rows = Vec::new(); 919 for row in cursor.raw() { 920 let row = match row { 921 Ok(row) => row, 922 Err(error) => return refused(error), 923 }; 924 rows.push( 925 row.into_iter() 926 .map(|value| match value { 927 SqlStorageValue::Null => serde_json::Value::Null, 928 SqlStorageValue::Boolean(flag) => flag.into(), 929 SqlStorageValue::Integer(number) => number.into(), 930 SqlStorageValue::Float(number) => number.into(), 931 SqlStorageValue::String(text) => text.into(), 932 SqlStorageValue::Blob(bytes) => format!("<{} bytes>", bytes.len()).into(), 933 }) 934 .collect(), 935 ); 936 } 937 Answered::Rows { columns, rows, read: cursor.rows_read() as u64 } 938 } 939 Admin::Moderate { input, listed } => { 940 self.changed(); 941 self.relisted(); 942 let say: SqlStorageValue = listed.map_or(SqlStorageValue::Null, |listed| i64::from(listed).into()); 943 match self.sql.exec("UPDATE asked SET moderated = ? WHERE input = ?", vec![say, input.into()]) { 944 Ok(_) => Answered::Done, 945 Err(error) => refused(error), 946 } 947 } 948 // Answered by the object itself (`Archive::fetch`): they need 949 // its state, and must work when the schema does not. 950 Admin::Bookmark { .. } | Admin::Restore { .. } => Answered::Refused("not a question for the tables".into()), 951 } 952 }
Fills in whose each old askers and ratings row is, where an
event says which browser asked or voted on that question: the
browser's id and the question hash to the row's who. A row from
before events were kept stays unnamed.
958 fn name_askers(&self) -> worker::Result<()> { 959 #[derive(Deserialize)] 960 struct Seen { 961 input: String, 962 browser: String, 963 } 964 let seen: Vec<Seen> = self.sql.exec("SELECT DISTINCT input, browser FROM events WHERE browser != '' AND input != '' AND what IN ('answer', 'vote')", None)?.to_array()?; 965 for Seen { input, browser } in seen { 966 let who = crate::asker(&browser, &input); 967 for table in ["askers", "ratings"] { 968 self.sql.exec( 969 &format!("UPDATE {table} SET browser = ? WHERE input = ? AND who = ? AND browser = ''"), 970 vec![browser.as_str().into(), input.as_str().into(), who.0.as_str().into()], 971 )?; 972 } 973 } 974 Ok(()) 975 }
Adds one to a tally. A failure loses one from a number on the home page and nothing else, so it is not allowed to fail a call.
984 fn count(&self, name: &str) { 985 self.changed(); 986 let counted = self.sql.exec( 987 "INSERT INTO counts (name, n) VALUES (?, 1) ON CONFLICT (name) DO UPDATE SET n = n + 1", 988 vec![name.into()], 989 ); 990 if let Err(error) = counted { 991 console_error!("the archive could not count a {name} request: {error}"); 992 } 993 }
Records what Jev said to input, and whether this is a new asking of
it: by a browser that has not asked it before, or by one that gave no
id. Only a new asking counts and moves it up the feed; Jev's answer
is kept either way.
1004 fn note(&self, input: &str, answers: &[Answer], llm: bool, listed: bool, who: Option<&Asker>, browser: &str, now_ms: f64) -> worker::Result<bool> { 1005 self.changed(); 1006 self.relisted(); 1007 let answers = serde_json::to_string(answers).map_err(|e| worker::Error::from(e.to_string()))?; 1008 let new = match who { 1009 Some(who) => { 1010 let cursor = self.sql.exec("INSERT OR IGNORE INTO askers (input, who, browser) VALUES (?, ?, ?)", vec![input.into(), who.0.as_str().into(), browser.into()])?; 1011 cursor.rows_written() > 0 1012 } 1013 None => true, 1014 }; 1015 if !new { 1016 self.sql.exec( 1017 "UPDATE asked SET answers = ?, listed = ?, llm = ? WHERE input = ?", 1018 vec![answers.into(), i64::from(listed).into(), i64::from(llm).into(), input.into()], 1019 )?; 1020 return Ok(false); 1021 } 1022 self.sql.exec( 1023 "INSERT INTO asked (input, answers, asked_ms, times, listed, llm) VALUES (?, ?, ?, 1, ?, ?) 1024 ON CONFLICT (input) DO UPDATE SET answers = excluded.answers, asked_ms = excluded.asked_ms, 1025 times = times + 1, listed = excluded.listed, llm = excluded.llm", 1026 vec![input.into(), answers.into(), now_ms.into(), i64::from(listed).into(), i64::from(llm).into()], 1027 )?; 1028 Ok(true) 1029 }
What a vote on input is on: the SHA-256 of the answers as kept, or
DECLINED for a question Jev declined and has not answered since.
None if it was neither, so there is nothing to vote on.
1034 fn answer_key(&self, input: &str) -> worker::Result<Option<String>> { 1035 #[derive(Deserialize)] 1036 struct Kept { 1037 answers: String, 1038 } 1039 let rows: Vec<Kept> = self.sql.exec("SELECT answers FROM asked WHERE input = ?", vec![input.into()])?.to_array()?; 1040 match rows.first() { 1041 Some(kept) => { 1042 use sha2::{Digest, Sha256}; 1043 Ok(Some(Sha256::digest(kept.answers.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect())) 1044 } 1045 None => Ok(self.was_declined(input)?.then(|| DECLINED.to_owned())), 1046 } 1047 }
who's vote on the answer to input as kept now; the vote it already
has takes it back.
1051 fn rate(&self, input: &str, who: &Asker, browser: &str, vote: Vote) -> worker::Result<Option<Rating>> { 1052 let Some(answer) = self.answer_key(input)? else { return Ok(None) }; 1053 let value = match vote { 1054 Vote::Up => 1, 1055 Vote::Down => -1, 1056 }; 1057 let had = self.rating_for(input, &answer, Some(who))?.mine; 1058 if had == Some(vote) { 1059 self.sql.exec( 1060 "DELETE FROM ratings WHERE input = ? AND answer = ? AND who = ?", 1061 vec![input.into(), answer.as_str().into(), who.0.as_str().into()], 1062 )?; 1063 } else { 1064 self.sql.exec( 1065 "INSERT INTO ratings (input, answer, who, vote, browser) VALUES (?, ?, ?, ?, ?) 1066 ON CONFLICT (input, answer, who) DO UPDATE SET vote = excluded.vote", 1067 vec![input.into(), answer.as_str().into(), who.0.as_str().into(), value.into(), browser.into()], 1068 )?; 1069 } 1070 Ok(Some(self.rating_for(input, &answer, Some(who))?)) 1071 }
Keeps comment beside who's standing vote on the answer to input.
The same words again for the same vote are not kept twice. The text is
a bound value only: it is in no message and no log.
1076 fn comment(&self, input: &str, who: &Asker, browser: &str, comment: &str) -> worker::Result<Result<(), Unsaid>> { 1077 let Some(answer) = self.answer_key(input)? else { return Ok(Err(Unsaid::NoVote)) }; 1078 let Some(vote) = self.rating_for(input, &answer, Some(who))?.mine else { return Ok(Err(Unsaid::NoVote)) }; 1079 #[derive(Deserialize)] 1080 struct Last { 1081 comment: String, 1082 vote: f64, 1083 } 1084 let last: Vec<Last> = self 1085 .sql 1086 .exec( 1087 "SELECT comment, vote FROM comments WHERE input = ? AND answer = ? AND who = ? ORDER BY id DESC LIMIT 1", 1088 vec![input.into(), answer.as_str().into(), who.0.as_str().into()], 1089 )? 1090 .to_array()?; 1091 let value = match vote { 1092 Vote::Up => 1, 1093 Vote::Down => -1, 1094 }; 1095 if last.first().is_some_and(|last| last.comment == comment && last.vote == f64::from(value)) { 1096 return Ok(Ok(())); 1097 } 1098 self.sql.exec( 1099 "INSERT INTO comments (at_ms, input, answer, who, browser, vote, comment) VALUES (?, ?, ?, ?, ?, ?, ?)", 1100 vec![js_sys::Date::now().into(), input.into(), answer.as_str().into(), who.0.as_str().into(), browser.into(), value.into(), comment.into()], 1101 )?; 1102 Ok(Ok(())) 1103 }
1105 fn rating(&self, input: &str, who: Option<&Asker>) -> worker::Result<Option<Rating>> { 1106 match self.answer_key(input)? { 1107 Some(answer) => Ok(Some(self.rating_for(input, &answer, who)?)), 1108 None => Ok(None), 1109 } 1110 } 1111 1112 fn rating_for(&self, input: &str, answer: &str, who: Option<&Asker>) -> worker::Result<Rating> { 1113 #[derive(Deserialize)] 1114 struct Votes { 1115 up: f64, 1116 down: f64, 1117 } 1118 #[derive(Deserialize)] 1119 struct Mine { 1120 vote: f64, 1121 } 1122 let votes: Vec<Votes> = self 1123 .sql 1124 .exec( 1125 "SELECT COALESCE(SUM(vote = 1), 0) AS up, COALESCE(SUM(vote = -1), 0) AS down FROM ratings WHERE input = ? AND answer = ?", 1126 vec![input.into(), answer.into()], 1127 )? 1128 .to_array()?; 1129 let mine = match who { 1130 Some(who) => { 1131 let rows: Vec<Mine> = self 1132 .sql 1133 .exec( 1134 "SELECT vote FROM ratings WHERE input = ? AND answer = ? AND who = ?", 1135 vec![input.into(), answer.into(), who.0.as_str().into()], 1136 )? 1137 .to_array()?; 1138 rows.first().map(|row| if row.vote > 0.0 { Vote::Up } else { Vote::Down }) 1139 } 1140 None => None, 1141 }; 1142 let votes = votes.first(); 1143 Ok(Rating { up: votes.map_or(0, |v| v.up as u32), down: votes.map_or(0, |v| v.down as u32), mine, declined: answer == DECLINED }) 1144 }
What Jev said to input, listed or not: it is for whoever holds the
link.
Keeps that input was a question Jev cannot take.
1157 fn decline(&self, input: &str, now_ms: f64) -> worker::Result<()> { 1158 self.sql.exec( 1159 "INSERT INTO declined (input, declined_ms) VALUES (?, ?) 1160 ON CONFLICT (input) DO UPDATE SET declined_ms = excluded.declined_ms, times = times + 1", 1161 vec![input.into(), now_ms.into()], 1162 )?; 1163 Ok(()) 1164 }
Was input declined, and never since answered?
1167 fn was_declined(&self, input: &str) -> worker::Result<bool> { 1168 #[derive(Deserialize)] 1169 struct Found { 1170 #[allow(dead_code)] 1171 input: String, 1172 } 1173 let found: Vec<Found> = self.sql.exec("SELECT input FROM declined WHERE input = ?", vec![input.into()])?.to_array()?; 1174 Ok(!found.is_empty() && self.answer(input)?.is_none()) 1175 }
The listed questions after after in the feed's order.
1178 fn older(&self, after: &Cursor) -> worker::Result<Vec<Entry>> { 1179 let rows: Vec<AskedRow> = self 1180 .sql 1181 .exec( 1182 "SELECT input, answers, asked_ms, times FROM asked 1183 WHERE COALESCE(moderated, listed) = 1 AND (asked_ms < ? OR (asked_ms = ? AND input < ?)) 1184 ORDER BY asked_ms DESC, input DESC LIMIT ?", 1185 vec![after.asked_ms.into(), after.asked_ms.into(), after.input.as_str().into(), i64::from(FEED).into()], 1186 )? 1187 .to_array()?; 1188 Ok(rows.into_iter().map(Entry::from).collect()) 1189 }
What the home page is made of has changed: read it again next time.
The questions the feed may show have changed.
The feed's questions that typed could be the start of.
1207 fn suggest(&self, typed: &str) -> worker::Result<Vec<Entry>> { 1208 if self.listed.borrow().is_none() { 1209 let rows: Vec<AskedRow> = self.sql.exec("SELECT input, answers, asked_ms, times FROM asked WHERE COALESCE(moderated, listed) = 1", None)?.to_array()?; 1210 self.listed.replace(Some(::archive::Listed::new(rows.into_iter().map(Entry::from)))); 1211 } 1212 Ok(self.listed.borrow().as_ref().map(|listed| listed.suggest(typed)).unwrap_or_default()) 1213 }
1215 fn home(&self) -> worker::Result<Home> { 1216 if let Some(home) = self.home.borrow().as_ref() { 1217 return Ok(home.clone()); 1218 } 1219 let home = self.read_home()?; 1220 self.home.replace(Some(home.clone())); 1221 Ok(home) 1222 } 1223 1224 fn read_home(&self) -> worker::Result<Home> { 1225 let tally: Vec<Tally> = self 1226 .sql 1227 .exec( 1228 "SELECT COUNT(*) AS questions, COALESCE(SUM(times), 0) AS asks, 1229 COALESCE(SUM(llm = 0), 0) AS no_llm FROM asked", 1230 None, 1231 )? 1232 .to_array()?; 1233 let tally = tally.first(); 1234 Ok(Home { 1235 lately: self.entries( 1236 "SELECT input, answers, asked_ms, times FROM asked WHERE COALESCE(moderated, listed) = 1 ORDER BY asked_ms DESC, input DESC LIMIT ?", 1237 FEED, 1238 )?, 1239 most: self.entries( 1240 "SELECT input, answers, asked_ms, times FROM asked WHERE COALESCE(moderated, listed) = 1 AND times > 1 1241 ORDER BY times DESC, asked_ms DESC LIMIT ?", 1242 MOST, 1243 )?, 1244 stats: Stats { 1245 questions: tally.map_or(0, |tally| tally.questions as u32), 1246 asks: tally.map_or(0, |tally| tally.asks as u32), 1247 no_llm: tally.map_or(0, |tally| tally.no_llm as u32), 1248 sent: self.counted(SENT)?, 1249 kept: self.counted(KEPT)?, 1250 clones: self.counted(&Fetch::Clone.counted(CODE))?, 1251 pulls: self.counted(&Fetch::Pull.counted(CODE))?, 1252 }, 1253 }) 1254 } 1255} 1256 1257#[durable_object] 1258pub struct Archive { 1259 state: State, 1260 shelf: Rc<Shelf>, 1261 past: Past,
Why the schema could not be brought up to date, if it could not.
Such an archive answers nothing but the owner's Bookmark and
Restore, which are how it is put right.
Sends one Jev request, inside the day's Jev budget.
1273async fn jev(shelf: Rc<Shelf>, input: String, wanted: Wanted, request: String) -> Called { 1274 let jev = match crate::jev(&shelf.env) { 1275 Ok(jev) => jev, 1276 Err(NoJev::Offline) => return failed("the Worker has no TypeSafe API key"), 1277 Err(NoJev::Broken(error)) => return failed(error), 1278 }; 1279 let prepared = match ask::wanted(&jev.model, &input, &wanted) { 1280 Ok(prepared) => prepared, 1281 Err(error) => return failed(error.to_string()), 1282 }; 1283 // The page shows `request` and the row is kept under it, so it has to be 1284 // the body that goes out. They can differ only while a deploy has the 1285 // Worker and this object on different code. 1286 if prepared.request != request { 1287 return failed("the archive would send a different request than the page prepared, so nothing was sent"); 1288 } 1289 let hold = match meter::hold(&shelf.env, Which::JevDollars, prepared.worst_case_dollars, jev.dollars_per_day).await { 1290 Ok(hold) => hold, 1291 Err(Refused::Spent(_)) => return Called::Spent(Pot::Jev), 1292 Err(Refused::Unreachable(error)) => return failed(error), 1293 }; 1294 let outcome = ask::send(&jev.client, &WorkerRuntime, &prepared).await; 1295 meter::settle(&shelf.env, Which::JevDollars, hold, outcome.dollars()).await; 1296 match outcome { 1297 Outcome::Answered { body, request_id, attempts, took, .. } => Called::Answered(Record { 1298 response: body, 1299 request_id, 1300 attempts, 1301 took_ms: took.as_secs_f64() * 1000.0, 1302 answered_ms: js_sys::Date::now(), 1303 sent_now: true, 1304 // Numbered when it is kept (`Shelf::keep`). 1305 version: 0, 1306 versions: 0, 1307 }), 1308 Outcome::Failed { error, request_id, took, .. } => { 1309 Called::Failed { error, request_id, took_ms: took.as_secs_f64() * 1000.0 } 1310 } 1311 } 1312}
Sends one request to the LLM, inside the day's neuron budget.
1315async fn llm(shelf: Rc<Shelf>, id: String, request: String) -> Called { 1316 let Some(model) = Model::find(&id) else { 1317 return failed(format!("{id} is not one of llm's candidates")); 1318 }; 1319 let hold = match meter::hold(&shelf.env, Which::Neurons, model.worst_case_neurons(&request), llm::FREE_NEURONS_PER_DAY).await { 1320 Ok(hold) => hold, 1321 Err(Refused::Spent(_)) => return Called::Spent(Pot::Llm), 1322 Err(Refused::Unreachable(error)) => return failed(error), 1323 }; 1324 let started = WorkerRuntime.now(); 1325 let ran = ai::run(&shelf.env, model.id, &request).await; 1326 let took_ms = (WorkerRuntime.now() - started).as_secs_f64() * 1000.0; 1327 // A call that never produced a reply is counted as free; one that did is 1328 // counted at what the reply says it used. 1329 let neurons = ran.as_ref().ok().and_then(|body| llm::parse(body).ok()).map_or(0.0, |reply| model.neurons(reply.usage)); 1330 meter::settle(&shelf.env, Which::Neurons, hold, neurons).await; 1331 match ran { 1332 Ok(response) => Called::Answered(Record { 1333 response, 1334 request_id: None, 1335 attempts: 1, 1336 took_ms, 1337 answered_ms: js_sys::Date::now(), 1338 sent_now: true, 1339 // Numbered when it is kept (`Shelf::keep`). 1340 version: 0, 1341 versions: 0, 1342 }), 1343 Err(error) => Called::Failed { error, request_id: None, took_ms }, 1344 } 1345}
1347impl Archive {
The response to request, sent at most once: the kept one, the one a
call already on the wire is about to get, or the one call gets now.
From the lookup to the insert into running nothing awaits, so no
other ask can run in between and start the same call.
pick says which kept response is wanted, and whether to send it
again regardless (Pick::Fresh) or never (Pick::KeptOnly).
1356 async fn once<F>(&self, sent_to: String, request: String, pick: Pick, call: impl FnOnce(Rc<Shelf>) -> F) -> worker::Result<Called> 1357 where 1358 F: Future<Output = Called> + 'static, 1359 { 1360 let wanted = match pick { 1361 Pick::Version(version) => Some(version), 1362 _ => None, 1363 }; 1364 if pick != Pick::Fresh 1365 && let Some(record) = self.shelf.kept(&sent_to, &request, wanted)? 1366 { 1367 self.shelf.count(KEPT); 1368 return Ok(Called::Answered(record)); 1369 } 1370 let key = (sent_to, request); 1371 let running = self.shelf.running.borrow().get(&key).cloned(); 1372 if pick == Pick::KeptOnly && running.is_none() { 1373 return Ok(Called::NotKept); 1374 } 1375 if let Some(running) = running { 1376 return Ok(match running.await { 1377 Called::Answered(record) => { 1378 self.shelf.count(KEPT); 1379 Called::Answered(record.kept()) 1380 } 1381 other => other, 1382 }); 1383 } 1384 let shelf = self.shelf.clone(); 1385 let call = call(shelf.clone()); 1386 let done = key.clone(); 1387 let work: Running = async move { 1388 let mut called = call.await; 1389 if let Called::Answered(record) = &mut called { 1390 shelf.count(SENT); 1391 match shelf.keep(&done.0, &done.1, record) { 1392 Ok(version) => { 1393 record.version = version; 1394 record.versions = version; 1395 } 1396 Err(error) => console_error!("the archive could not keep a response, so its request may be sent again: {error}"), 1397 } 1398 } 1399 shelf.running.borrow_mut().remove(&done); 1400 called 1401 } 1402 .boxed_local() 1403 .shared(); 1404 self.shelf.running.borrow_mut().insert(key, work.clone()); 1405 self.state.wait_until(work.clone().map(|_| ())); 1406 Ok(work.await) 1407 } 1408}
1410impl Archive {
Sends live to every open page. A page that cannot be told has gone,
and its close is on its way.
Sends every open page the activity feeds as they now stand: asked
at the top of the feed, if it was a question the feed may show, and
the most asked and the tally whole. Nobody open, nothing to render.
1427 fn activity(&self, asked: Option<&str>) { 1428 if self.state.get_websockets().is_empty() { 1429 return; 1430 } 1431 let top = match asked.map(|input| self.shelf.answer(input)) { 1432 Some(Ok(entry)) => entry, 1433 Some(Err(error)) => { 1434 console_error!("the archive could not read the question to send: {error}"); 1435 None 1436 } 1437 None => None, 1438 }; 1439 match self.shelf.home() { 1440 Ok(home) => { 1441 let markup = maud::html! { 1442 @if let Some(top) = &top { (crate::view::lately_top(top)) } 1443 (crate::view::most(&home.most)) 1444 (crate::view::tally(Some(&home.stats))) 1445 }; 1446 self.broadcast(&Live::Patch(markup.into_string())); 1447 } 1448 Err(error) => console_error!("the archive could not read the feeds to send: {error}"), 1449 } 1450 }
The pages open now: every socket still open, leaving not counted.
1453 fn open(&self, leaving: Option<&WebSocket>) -> Vec<WebSocket> { 1454 self.state 1455 .get_websockets() 1456 .into_iter() 1457 .filter(|socket| { 1458 let raw: &worker::web_sys::WebSocket = socket.as_ref(); 1459 raw.ready_state() == worker::web_sys::WebSocket::OPEN 1460 }) 1461 .filter(|socket| leaving.is_none_or(|leaving| !same(socket, leaving))) 1462 .collect() 1463 }
Tells to how many pages are open and where they are.
1466 fn online(&self, to: &[WebSocket], open: &[WebSocket]) { 1467 self.send(to, &Live::Online(open.len() as u32)); 1468 let places = open.iter().map(|socket| seen(socket).map(|seen| seen.place).unwrap_or_default()); 1469 self.send(to, &Live::Patch(crate::view::places(&::archive::places(places)).into_string())); 1470 }
Everyone hears that pages came or went once, ONLINE_EVERY_MS after
the first change, however many changed in between. A deploy closes
every socket and they all reconnect within a second or two; telling
every page about every one would be pages² messages.
What is kept on a page's socket, if anything.
Whether two handles are the same socket.
1497impl DurableObject for Archive { 1498 fn new(state: State, env: Env) -> Self { 1499 let (past, state) = Past::of(state); 1500 let shelf = Rc::new(Shelf { env, sql: state.storage().sql(), running: RefCell::new(HashMap::new()), home: RefCell::new(None), listed: RefCell::new(None), finished_to: std::cell::Cell::new(i64::MIN) }); 1501 // An archive that cannot bring its schema up to date must not serve: 1502 // it would keep responses in a shape the next start cannot read. 1503 let broken = Shelf::migrate(&shelf, &past).err(); 1504 if let Some(why) = &broken { 1505 console_error!("the archive's migrations did not run: {why}"); 1506 } 1507 Archive { state, shelf, past, broken } 1508 } 1509 1510 async fn fetch(&self, mut request: Request) -> worker::Result<Response> { 1511 // `/live`: an open page, told what happens while it is open. The 1512 // socket is the object's, accepted for hibernation, so an idle page 1513 // keeps nothing awake. 1514 // The admin backend's door. Only its own Worker sends a request here 1515 // (`archive::ADMIN`); this site's Worker has no code that does. 1516 if request.path() == ::archive::ADMIN { 1517 let ask: ::archive::Admin = serde_json::from_str(&request.text().await?).map_err(|e| worker::Error::from(e.to_string()))?; 1518 let said = |answered: ::archive::Answered| Response::ok(serde_json::to_string(&answered).map_err(|e| worker::Error::from(e.to_string()))?); 1519 let named = |bookmark: Result<String, String>| bookmark.map_or_else(::archive::Answered::Refused, ::archive::Answered::Bookmark); 1520 match &ask { 1521 ::archive::Admin::Bookmark { at_ms } => return said(named(self.past.bookmark(*at_ms).await)), 1522 ::archive::Admin::Restore { bookmark } => { 1523 let undo = self.past.restore(bookmark).await; 1524 if undo.is_ok() { 1525 // The answer leaves first; then the object ends, and 1526 // starts again as it was. 1527 let past = self.past.clone(); 1528 wasm_bindgen_futures::spawn_local(async move { 1529 worker::Delay::from(std::time::Duration::from_millis(250)).await; 1530 past.restart(); 1531 }); 1532 } 1533 return said(named(undo)); 1534 } 1535 _ => {} 1536 } 1537 if let Some(why) = &self.broken { 1538 return said(::archive::Answered::Refused(format!("the archive's migrations did not run: {why}"))); 1539 } 1540 let moderated = matches!(ask, ::archive::Admin::Moderate { .. }); 1541 let answered = self.shelf.admin(ask); 1542 if moderated { 1543 // Open pages get the feeds as they now are. 1544 self.activity(None); 1545 } 1546 return Response::ok(serde_json::to_string(&answered).map_err(|e| worker::Error::from(e.to_string()))?); 1547 } 1548 if let Some(why) = &self.broken { 1549 return Response::error(format!("the archive's migrations did not run: {why}"), 503); 1550 } 1551 if request.headers().get("upgrade")?.is_some_and(|upgrade| upgrade.eq_ignore_ascii_case("websocket")) { 1552 let pair = WebSocketPair::new()?; 1553 self.state.accept_web_socket(&pair.server); 1554 // Where it connected from, as the Worker passed it on: kept on the 1555 // socket, which hibernation keeps, and gone when it closes. 1556 let header = |name: &str| -> Option<String> { 1557 let value = request.headers().get(name).ok().flatten()?; 1558 ::archive::percent_decoded(&value) 1559 }; 1560 // Who connected, kept as an event; the socket remembers which, 1561 // so the event that says it left can say who. 1562 let now = js_sys::Date::now(); 1563 let came = header(EVENT) 1564 .and_then(|event| serde_json::from_str::<::archive::Event>(&event).ok()) 1565 .and_then(|event| self.shelf.happened(&event.named("live"), now).ok()); 1566 let seen = Seen { place: Place::new(header(COUNTRY).as_deref(), header(CITY).as_deref()), passed: header(PLACED).is_some(), at_ms: now, event: came }; 1567 let _ = pair.server.serialize_attachment(&seen); 1568 self.send(std::slice::from_ref(&pair.server), &Live::Build(crate::BUILD.to_owned())); 1569 // The page that came hears how things stand now, itself counted; 1570 // the others hear it with the next change. 1571 let open = self.open(None); 1572 self.online(std::slice::from_ref(&pair.server), &open); 1573 self.soon().await?; 1574 // A page from an earlier build hears what this one changed. A 1575 // page says its build in `?build=`; one too old to say is earlier. 1576 let theirs = request.url()?.query_pairs().find(|(key, _)| key == "build").map(|(_, build)| build.into_owned()); 1577 if !crate::NOTE.is_empty() && theirs.as_deref() != Some(crate::BUILD) { 1578 self.send(std::slice::from_ref(&pair.server), &Live::Toast(crate::view::toast::note(crate::NOTE).into_string())); 1579 } 1580 return Response::from_websocket(pair.client); 1581 } 1582 let text = request.text().await?; 1583 let ask: Ask = serde_json::from_str(&text).map_err(|e| worker::Error::from(e.to_string()))?; 1584 let told = match ask { 1585 Ask::Jev { input, wanted, request, pick } => { 1586 let body = request.clone(); 1587 Told::Called(self.once(jev_protocol::ENDPOINT.to_owned(), request, pick, |shelf| jev(shelf, input, wanted, body)).await?) 1588 } 1589 Ask::Llm { model, request, pick } => { 1590 let body = request.clone(); 1591 Told::Called(self.once(model.clone(), request, pick, |shelf| llm(shelf, model, body)).await?) 1592 } 1593 Ask::Asked { input, answers, llm, listed, who, browser } => { 1594 // A browser asking what it asked before is not news. 1595 if self.shelf.note(&input, &answers, llm, listed, who.as_ref(), &browser, js_sys::Date::now())? { 1596 // Only what the feed may show is shown; anything else is 1597 // "someone asked". The owner's say comes before Jev's. 1598 let listed = self.shelf.shown(&input)?; 1599 let shown = listed.then_some((input.as_str(), answers.as_slice())); 1600 self.broadcast(&Live::Toast(crate::view::toast::asked(shown).into_string())); 1601 self.activity(listed.then_some(input.as_str())); 1602 } 1603 Told::Noted 1604 } 1605 Ask::Event(event) => { 1606 self.shelf.happened(&event, js_sys::Date::now())?; 1607 Told::Noted 1608 } 1609 Ask::Fetched { repo, fetch } => { 1610 self.shelf.count(&fetch.counted(&repo)); 1611 // jevcrates comes along with every project cloned with its 1612 // submodules, so its own fetches would toast twice for one 1613 // clone; it is counted and not told. 1614 if crate::clone::Repo::named(&repo).is_some_and(|repo| repo != crate::clone::Repo::Jevcrates) { 1615 self.broadcast(&Live::Toast(crate::view::toast::fetched(fetch, &repo).into_string())); 1616 self.activity(None); 1617 } 1618 Told::Noted 1619 } 1620 Ask::Home => Told::Home(self.shelf.home()?), 1621 Ask::Answer { input } => Told::Answer(self.shelf.answer(&input)?), 1622 Ask::Declined { input } => { 1623 self.shelf.decline(&input, js_sys::Date::now())?; 1624 Told::Noted 1625 } 1626 Ask::WasDeclined { input } => Told::WasDeclined(self.shelf.was_declined(&input)?), 1627 Ask::Older { after } => Told::Older(self.shelf.older(&after)?), 1628 Ask::Suggest { typed } => Told::Suggested(self.shelf.suggest(&typed)?), 1629 Ask::Rate { input, who, vote, browser } => Told::Rating(self.shelf.rate(&input, &who, &browser, vote)?), 1630 Ask::Rating { input, who } => Told::Rating(self.shelf.rating(&input, who.as_ref())?), 1631 Ask::Comment { input, who, browser, comment } => Told::Commented(self.shelf.comment(&input, &who, &browser, &comment)?), 1632 }; 1633 Response::ok(serde_json::to_string(&told).map_err(|e| worker::Error::from(e.to_string()))?) 1634 }
A page says nothing the archive listens to.
1641 async fn websocket_close(&self, socket: WebSocket, _code: usize, _reason: String, _clean: bool) -> worker::Result<()> { 1642 // The page has gone: the event that said it came, again, as `left`. 1643 if let Some(seen) = seen(&socket) 1644 && let Some(came) = seen.event 1645 { 1646 let now = js_sys::Date::now(); 1647 let _ = self.shelf.left(came, now - seen.at_ms, now); 1648 } 1649 self.soon().await 1650 } 1651 1652 async fn websocket_error(&self, _socket: WebSocket, _error: worker::Error) -> worker::Result<()> { 1653 self.soon().await 1654 }
Pages came or went since the last time everyone was told. A page that came through a Worker that passes no place (during a deploy) is asked to reconnect once the deploy has settled, so it gets one; until then the alarm comes back for it.
1660 async fn alarm(&self) -> worker::Result<Response> { 1661 let now = js_sys::Date::now(); 1662 let mut waiting = false; 1663 for socket in self.open(None) { 1664 let seen = seen(&socket); 1665 if Seen::stale(seen.as_ref(), now) { 1666 let _ = socket.close(Some(1012), Some("reconnect to be placed")); 1667 } else if seen.is_some_and(|seen| !seen.passed) { 1668 waiting = true; 1669 } 1670 } 1671 let open = self.open(None); 1672 self.online(&open, &open); 1673 if waiting { 1674 self.state.storage().set_alarm(std::time::Duration::from_millis(::archive::REPLACE_AFTER_MS as u64)).await?; 1675 } 1676 Response::ok("") 1677 } 1678}
A call's response: kept, or sent now. Every Jev and LLM call the Worker makes goes through here.
Records that input was asked and what Jev said. A failure loses one
line of the feed and nothing else.
1695pub async fn asked(env: &Env, input: &str, answers: Vec<Answer>, llm: bool, listed: bool, browser: Option<&str>) { 1696 let who = browser.map(|browser| crate::asker(browser, input)); 1697 let _ = tell(env, Ask::Asked { input: input.to_owned(), answers, llm, listed, who, browser: browser.unwrap_or_default().to_owned() }).await; 1698}
What Jev said to input the last time it was asked, if it ever was.
Reading it sends nothing, so a link preview costs nothing.
Keeps that input was a question Jev cannot take. A failure loses the
card's wording for that one link and nothing else.
Was input a question Jev could not take, and never answered since?
Sends nothing to anyone, so a link preview costs nothing.
The feed and the numbers, for the home page. None if the archive
cannot be asked.
Someone cloned or pulled repo.
/live: the request, handed to the archive, which keeps the socket.
How long after pages come or go every page is told, at most once.
1730const ONLINE_EVERY_MS: u64 = 1000;
The headers the Worker passes a page's place to the archive in.
Set by a Worker that passes places on, with or without one to pass.
1736pub const PLACED: &str = "x-lmjtfy-placed";
The request as an archive::Event, JSON and percent-encoded: who
connected. Set by the Worker, over any the page sent.
1739pub const EVENT: &str = "x-lmjtfy-event";
Keeps an event. A failure loses that one row and nothing else.
who's vote on Jev's answer to input, and the votes after it. None
if there is nothing to vote on or the archive cannot be asked.
1753pub async fn rate(env: &Env, input: &str, browser: &str, vote: Vote) -> Option<Rating> { 1754 match tell(env, Ask::Rate { input: input.to_owned(), who: crate::asker(browser, input), vote, browser: browser.to_owned() }).await { 1755 Ok(Told::Rating(rating)) => rating, 1756 _ => None, 1757 } 1758}
who's comment on their vote on Jev's answer to input. The text goes to
the archive and nowhere else: a failure says only that it failed.
1762pub async fn comment(env: &Env, input: &str, browser: &str, comment: String) -> Result<(), Unsaid> { 1763 match tell(env, Ask::Comment { input: input.to_owned(), who: crate::asker(browser, input), browser: browser.to_owned(), comment }).await { 1764 Ok(Told::Commented(done)) => done, 1765 _ => Err(Unsaid::Failed), 1766 } 1767}
The votes on Jev's answer to input, and who's.
The page of the feed after after. None if the archive cannot be
asked.
The feed's questions that what has been typed could be the start of. Nothing, if the archive cannot be asked: a suggestion is a convenience.
Every (table, column) the migrations make: CREATE TABLE columns
and ALTER TABLE ... ADD COLUMN.
1808 fn columns() -> Vec<(String, String)> { 1809 let mut found = Vec::new(); 1810 for statement in MIGRATIONS.iter().flat_map(|step| step.iter()) { 1811 let words: Vec<&str> = statement.split_whitespace().collect(); 1812 // A table a later step drops is not in the archive any more. 1813 if words.first() == Some(&"DROP") { 1814 let dropped = words.last().copied().unwrap_or_default(); 1815 found.retain(|(table, _)| table != dropped); 1816 continue; 1817 } 1818 if let Some(at) = words.iter().position(|word| *word == "TABLE") { 1819 // A `_finished` table has its view's columns, which a test 1820 // below holds; the views are in the README's table. 1821 if words.get(at + 1).is_some_and(|table| table.ends_with("_finished")) { 1822 continue; 1823 } 1824 let table = words[at..].iter().find(|word| !matches!(**word, "TABLE" | "IF" | "NOT" | "EXISTS")).copied(); 1825 let Some(table) = table.map(|table| table.trim_end_matches('(')) else { continue }; 1826 if words.first() == Some(&"CREATE") { 1827 let body = statement.split_once('(').map(|(_, body)| body).unwrap_or_default(); 1828 // Columns until the table's own key clause, whose list of 1829 // names is not columns. 1830 for line in body.split(',') { 1831 let column = line.split_whitespace().next().unwrap_or_default(); 1832 if column == "PRIMARY" { 1833 break; 1834 } 1835 if !column.is_empty() { 1836 found.push((table.to_owned(), column.to_owned())); 1837 } 1838 } 1839 } else if let Some(at) = words.iter().position(|word| *word == "COLUMN") { 1840 found.push((table.to_owned(), words[at + 1].to_owned())); 1841 } 1842 } 1843 } 1844 found 1845 }
1847 const DAY_MS: f64 = 86_400_000.0;
An archive as the migrations make it, each step all or nothing.
1850 fn archive() -> rusqlite::Connection { 1851 let mut archive = rusqlite::Connection::open_in_memory().unwrap(); 1852 // As `Shelf::migrate` begins. 1853 archive.execute("CREATE TABLE IF NOT EXISTS migrated (version INTEGER PRIMARY KEY)", []).unwrap(); 1854 for (index, step) in MIGRATIONS.iter().enumerate() { 1855 let all = archive.transaction().unwrap(); 1856 for statement in *step { 1857 all.execute(statement, []).unwrap_or_else(|error| panic!("step {}: {error}\n{statement}", index + 1)); 1858 } 1859 all.execute("INSERT INTO migrated (version) VALUES (?)", [index as i64 + 1]).unwrap(); 1860 all.commit().unwrap(); 1861 } 1862 archive 1863 }
1865 fn happened(archive: &rusqlite::Connection, at_ms: f64, event: &::archive::Event) { 1866 let columns = ::archive::Event::COLUMNS; 1867 let mut values: Vec<rusqlite::types::Value> = vec![at_ms.into()]; 1868 values.extend(event.values().into_iter().map(|cell| match cell { 1869 ::archive::event::Cell::Text(text) => text.into(), 1870 ::archive::event::Cell::Number(number) => number.into(), 1871 ::archive::event::Cell::Null => rusqlite::types::Value::Null, 1872 })); 1873 let marks = vec!["?"; values.len()].join(", "); 1874 archive.execute(&format!("INSERT INTO events (at_ms, {}) VALUES ({marks})", columns.join(", ")), rusqlite::params_from_iter(values)).unwrap(); 1875 }
Every row of sql, as text, in order.
1878 fn rows(archive: &rusqlite::Connection, sql: &str) -> Vec<String> { 1879 let mut statement = archive.prepare(sql).unwrap(); 1880 let width = statement.column_count(); 1881 let rows = statement.query_map([], |row| Ok((0..width).map(|at| format!("{:?}", row.get_unwrap::<_, rusqlite::types::Value>(at))).collect::<Vec<_>>().join("|"))).unwrap(); 1882 let mut rows: Vec<String> = rows.map(Result::unwrap).collect(); 1883 rows.sort(); 1884 rows 1885 }
Three days of a few visitors: a browser that comes back, one from a link, a bot, and a script with no cookie.
1889 fn visited(archive: &rusqlite::Connection) { 1890 let event = |what: &str, path: &str, client: &str, browser: &str, ip: &str, country: &str, city: &str| ::archive::Event { 1891 what: what.into(), 1892 path: path.into(), 1893 client: client.into(), 1894 browser: browser.into(), 1895 status: 200.0, 1896 family: "Firefox".into(), 1897 os: "Linux".into(), 1898 origin: ::archive::event::Origin { ip: ip.into(), country: country.into(), city: city.into(), network: "A Net".into(), ..Default::default() }, 1899 ..Default::default() 1900 }; 1901 for day in 100..103 { 1902 let at = |hour: f64| (f64::from(day) + hour / 24.0) * DAY_MS; 1903 happened(archive, at(1.0), &::archive::Event { first: f64::from(day == 100), daily: 1.0, session: 1.0, ..event("view", "/", "browser", "a", "1.1.1.1", "US", "Austin") }); 1904 happened(archive, at(1.1), &event("gate", "/gate", "browser", "a", "1.1.1.1", "US", "Austin")); 1905 happened(archive, at(1.2), &::archive::Event { input: "is it?".into(), detail: "answered".into(), ..event("answer", "/ask", "browser", "a", "1.1.1.1", "US", "Austin") }); 1906 happened(archive, at(5.0), &event("view", "/rules", "bot", "", "9.9.9.9", "DE", "")); 1907 happened(archive, at(5.0), &::archive::Event { status: 404.0, ..event("view", "/.env", "other", "", "8.8.8.8", "", "") }); 1908 } 1909 happened(archive, 101.5 * DAY_MS, &::archive::Event { referrer: "https://news.example/item?id=1".into(), first: 1.0, daily: 1.0, session: 1.0, ..event("view", "/", "browser", "b", "2.2.2.2", "GB", "Bexley") }); 1910 happened(archive, 101.6 * DAY_MS, &::archive::Event { took_ms: 9000.0, ..event("read", "/", "browser", "b", "2.2.2.2", "GB", "Bexley") }); 1911 happened(archive, 101.7 * DAY_MS, &::archive::Event { referrer: "https://lmjtfy.fun/rules".into(), ..event("view", "/rules", "browser", "b", "2.2.2.2", "GB", "Bexley") }); 1912 }
1914 #[test] 1915 fn every_migration_runs_and_makes_what_is_finished() { 1916 let archive = archive(); 1917 let mut tables = rows(&archive, "SELECT substr(name, 1, length(name) - 9) FROM sqlite_master WHERE type = 'table' AND name LIKE '%\\_finished' ESCAPE '\\'"); 1918 let mut named = rows(&archive, "SELECT name FROM finished"); 1919 let mut wanted: Vec<String> = FINISHED.iter().map(|name| format!("Text({name:?})")).collect(); 1920 (tables.sort(), named.sort(), wanted.sort()); 1921 assert_eq!(tables, wanted); 1922 assert_eq!(named, wanted); 1923 for name in FINISHED { 1924 // The table is the shape of the view it is copied from, and of the one that is asked. 1925 let columns = |of: &str| rows(&archive, &format!("SELECT cid, name FROM pragma_table_info('{of}')")); 1926 assert_eq!(columns(&format!("{name}_finished")), columns(&format!("{name}_live")), "{name}"); 1927 assert_eq!(columns(&format!("{name}_finished")), columns(name), "{name}"); 1928 } 1929 } 1930 1931 #[test] 1932 fn a_finished_day_is_what_it_was_live() { 1933 let archive = archive(); 1934 visited(&archive); 1935 let all = |archive: &rusqlite::Connection| FINISHED.map(|name| rows(archive, &format!("SELECT * FROM {name}"))); 1936 let live = all(&archive); 1937 assert!(live.iter().all(|rows| !rows.is_empty()), "{live:?}"); 1938 // Days 100 and 101 are over; 102 is today. A day finished again is left as it is. 1939 for _ in 0..2 { 1940 for name in FINISHED { 1941 let [put_aside, copy] = finish_sql(name); 1942 for day in [100, 101] { 1943 archive.execute(&put_aside, [day]).unwrap(); 1944 archive.execute(©, [day]).unwrap(); 1945 archive.execute("UPDATE finished SET through = ? WHERE name = ?", rusqlite::params![day, name]).unwrap(); 1946 } 1947 } 1948 } 1949 assert_eq!(all(&archive), live); 1950 for name in FINISHED { 1951 // What is over is in the table, and only today is read from `events`. 1952 assert_eq!(rows(&archive, &format!("SELECT COUNT(*) FROM {name}_finished WHERE day NOT IN (100, 101)")), ["Integer(0)"], "{name}"); 1953 assert_eq!(rows(&archive, &format!("SELECT COUNT(*) FROM {name}_live WHERE day != 102")), ["Integer(0)"], "{name}"); 1954 } 1955 } 1956 1957 #[test] 1958 fn the_views_count_what_the_site_means() { 1959 let archive = archive(); 1960 visited(&archive); 1961 // A page given to a browser is a view; a bot's and one not found are not. 1962 assert_eq!(rows(&archive, "SELECT day, views, visitors, new, asks, answered, read_ms FROM days"), ["Integer(100)|Integer(1)|Real(1.0)|Real(1.0)|Integer(1)|Integer(1)|Integer(0)", "Integer(101)|Integer(3)|Real(2.0)|Real(1.0)|Integer(1)|Integer(1)|Real(9000.0)", "Integer(102)|Integer(1)|Real(1.0)|Real(0.0)|Integer(1)|Integer(1)|Integer(0)"]); 1963 assert_eq!(rows(&archive, "SELECT label, n FROM pages_by_day WHERE day = 101"), ["Text(\"/\")|Integer(2)", "Text(\"/rules\")|Integer(1)"]); 1964 assert_eq!(rows(&archive, "SELECT label, n FROM questions_by_day WHERE day = 100"), ["Text(\"is it?\")|Integer(1)"]); 1965 // The site's own pages are not where a visitor came from. 1966 assert_eq!(rows(&archive, "SELECT day, label, n FROM referrers_by_day"), ["Integer(101)|Text(\"news.example\")|Integer(1)"]); 1967 // Browser a came on all three days: three days' numbers, one visitor. 1968 assert_eq!(rows(&archive, "SELECT label, SUM(n) FROM countries_by_day GROUP BY label"), ["Text(\"GB\")|Integer(1)", "Text(\"US\")|Integer(3)"]); 1969 assert_eq!(rows(&archive, "SELECT label, COUNT(DISTINCT who) FROM country_visitors GROUP BY label"), ["Text(\"GB\")|Integer(1)", "Text(\"US\")|Integer(1)"]); 1970 assert_eq!(rows(&archive, "SELECT label, n FROM cities_by_day WHERE day = 101"), ["Text(\"Austin, US\")|Integer(1)", "Text(\"Bexley, GB\")|Integer(1)"]); 1971 assert_eq!(rows(&archive, "SELECT label, n FROM agents_by_day WHERE day = 101"), ["Text(\"Firefox on Linux\")|Integer(2)"]); 1972 // The trees: a page under a folder is a hit on the folder too, and the top has no parent. 1973 happened(&archive, 101.8 * DAY_MS, &::archive::Event { what: "view".into(), path: "/lmjtfy.git/apps".into(), client: "browser".into(), browser: "b".into(), status: 200.0, ..Default::default() }); 1974 assert_eq!(rows(&archive, "SELECT page, views, browsers, by_bots, not_found FROM pages WHERE parent IS NULL"), ["Text(\"/\")|Integer(4)|Integer(2)|Integer(0)|Integer(0)", "Text(\"/.env\")|Integer(0)|Integer(0)|Integer(0)|Integer(3)", "Text(\"/lmjtfy.git\")|Integer(1)|Integer(1)|Integer(0)|Integer(0)", "Text(\"/rules\")|Integer(1)|Integer(1)|Integer(3)|Integer(0)"]); 1975 assert_eq!(rows(&archive, "SELECT page, has_more FROM pages WHERE parent = '/lmjtfy.git'"), ["Text(\"/lmjtfy.git/apps\")|Integer(0)"]); 1976 assert_eq!(rows(&archive, "SELECT place, kind, browsers, addresses FROM places WHERE parent IS NULL"), ["Text(\"DE\")|Text(\"country\")|Integer(0)|Integer(1)", "Text(\"GB\")|Text(\"country\")|Integer(1)|Integer(1)", "Text(\"US\")|Text(\"country\")|Integer(1)|Integer(1)"]); 1977 assert_eq!(rows(&archive, "SELECT place, kind FROM places WHERE parent = 'GB / ?'"), ["Text(\"GB / ? / Bexley\")|Text(\"city\")"]); 1978 assert_eq!(rows(&archive, "SELECT browser, viewed, typed, asked, first FROM browser_days WHERE day = 101"), ["Text(\"a\")|Integer(1)|Integer(1)|Integer(1)|Real(0.0)", "Text(\"b\")|Integer(1)|Integer(0)|Integer(0)|Real(1.0)"]); 1979 } 1980 1981 #[test] 1982 fn what_is_kept_can_be_added_to_and_not_changed() { 1983 let archive = archive(); 1984 visited(&archive); 1985 archive.execute("INSERT INTO versions (sent_to, request, version, response, attempts, took_ms, answered_ms) VALUES ('jev', '{}', 1, '{}', 1, 1.0, 1.0)", []).unwrap(); 1986 for (statement, why) in [ 1987 ("UPDATE versions SET response = 'other'", "a kept response is never changed"), 1988 ("DELETE FROM versions", "a kept response is never deleted"), 1989 ("UPDATE events SET input = ''", "an event is never changed"), 1990 ("DELETE FROM events WHERE what = 'gate'", "an event is never deleted"), 1991 ] { 1992 let refused = archive.execute(statement, []).unwrap_err().to_string(); 1993 assert!(refused.contains(why), "{statement}: {refused}"); 1994 } 1995 assert_eq!(rows(&archive, "SELECT COUNT(*) FROM versions"), ["Integer(1)"]); 1996 } 1997 1998 #[test] 1999 fn the_feed_is_read_in_its_order_from_an_index() { 2000 let archive = archive(); 2001 // `asked_lately` went in step 18: the feed's own index is what reads it. 2002 let plan = rows(&archive, "EXPLAIN QUERY PLAN SELECT input FROM asked WHERE COALESCE(moderated, listed) = 1 ORDER BY asked_ms DESC, input DESC LIMIT 50").join(" "); 2003 assert!(plan.contains("asked_feed") && !plan.contains("TEMP B-TREE"), "{plan}"); 2004 assert_eq!(rows(&archive, "SELECT name FROM pragma_table_info('migrated')"), ["Text(\"at_ms\")", "Text(\"build\")", "Text(\"version\")"]); 2005 } 2006 2007 #[test] 2008 fn a_comment_is_read_beside_its_vote_and_never_changed() { 2009 let archive = archive(); 2010 let rate = |who: &str, vote: i64| archive.execute("INSERT INTO ratings (input, answer, who, vote, browser) VALUES ('is it?', 'h', ?, ?, ?)", rusqlite::params![who, vote, format!("b-{who}")]).unwrap(); 2011 let say = |who: &str, vote: i64, text: &str| archive.execute("INSERT INTO comments (at_ms, input, answer, who, browser, vote, comment) VALUES (1000, 'is it?', 'h', ?, ?, ?, ?)", rusqlite::params![who, format!("b-{who}"), vote, text]).unwrap(); 2012 rate("a", 1); 2013 rate("b", -1); 2014 rate("c", 1); 2015 say("a", 1, "first"); 2016 say("a", 1, "<b>second</b>"); 2017 say("b", -1, "gone"); 2018 archive.execute("DELETE FROM ratings WHERE who = 'b'", []).unwrap(); 2019 // Every comment, with the vote then and now, and whether it is the latest. 2020 assert_eq!( 2021 rows(&archive, "SELECT browser, vote_then, vote_now, latest, comment FROM vote_comments"), 2022 [ 2023 "Text(\"b-a\")|Text(\"up\")|Text(\"up\")|Integer(0)|Text(\"first\")", 2024 "Text(\"b-a\")|Text(\"up\")|Text(\"up\")|Integer(1)|Text(\"<b>second</b>\")", 2025 "Text(\"b-b\")|Text(\"down\")|Null|Integer(1)|Text(\"gone\")", 2026 ] 2027 ); 2028 // Every vote standing, with its latest comment or none. 2029 assert_eq!( 2030 rows(&archive, "SELECT browser, vote, comment FROM votes_commented"), 2031 ["Text(\"b-a\")|Text(\"up\")|Text(\"<b>second</b>\")", "Text(\"b-c\")|Text(\"up\")|Null"] 2032 ); 2033 for refused in ["UPDATE comments SET comment = 'x'", "DELETE FROM comments"] { 2034 assert!(archive.execute(refused, []).unwrap_err().to_string().contains("a comment is never"), "{refused}"); 2035 } 2036 } 2037 2038 #[test] 2039 fn the_events_table_is_the_shape_of_an_event() { 2040 let made: Vec<String> = columns().into_iter().filter(|(table, _)| table == "events").map(|(_, column)| column).collect(); 2041 let mut wanted = vec!["id", "at_ms"]; 2042 wanted.extend(::archive::Event::COLUMNS); 2043 // Worked out from `at_ms`, not written. 2044 wanted.push("day"); 2045 assert_eq!(made, wanted); 2046 } 2047 2048 #[test] 2049 fn the_readme_diagram_has_every_column_the_migrations_make() { 2050 let readme = include_str!("../README.md"); 2051 let diagram = readme.split("### The archive").nth(1).and_then(|rest| rest.split("```").nth(1)).expect("the archive's erDiagram"); 2052 let columns = columns(); 2053 assert!(columns.len() >= 15, "{columns:?}"); 2054 for (table, column) in columns { 2055 let entity = diagram.split(&format!(" {table} {{")).nth(1).and_then(|rest| rest.split('}').next()); 2056 let entity = entity.unwrap_or_else(|| panic!("the diagram has no `{table}`")); 2057 assert!( 2058 entity.lines().any(|line| line.split_whitespace().nth(1) == Some(column.as_str())), 2059 "the diagram's `{table}` lacks `{column}`" 2060 ); 2061 } 2062 assert!(!diagram.contains(" calls {"), "`calls` became `versions` (MIGRATIONS step 7)"); 2063 } 2064 2065 #[test] 2066 fn a_vote_on_a_declined_question_is_keyed_apart_and_tied_to_its_record() { 2067 use sha2::{Digest, Sha256}; 2068 let archive = archive(); 2069 let hash: String = Sha256::digest(b"[]").iter().map(|byte| format!("{byte:02x}")).collect(); 2070 assert_ne!(hash, super::DECLINED); 2071 archive.execute("INSERT INTO declined (input, declined_ms) VALUES ('what is rayleigh scattering?', 5)", []).unwrap(); 2072 archive.execute("INSERT INTO ratings (input, answer, who, vote, browser) VALUES ('what is rayleigh scattering?', 'declined', 'w', -1, 'b-w')", []).unwrap(); 2073 archive.execute("INSERT INTO ratings (input, answer, who, vote, browser) VALUES ('is it?', ?, 'w', 1, 'b-w')", [&hash]).unwrap(); 2074 archive.execute("INSERT INTO comments (at_ms, input, answer, who, browser, vote, comment) VALUES (7, 'what is rayleigh scattering?', 'declined', 'w', 'b-w', -1, 'it is physics')", []).unwrap(); 2075 // Which kind a row is, worked out and not written. 2076 assert_eq!(rows(&archive, "SELECT input, about FROM ratings ORDER BY input"), ["Text(\"is it?\")|Text(\"answered\")", "Text(\"what is rayleigh scattering?\")|Text(\"declined\")"]); 2077 assert!(archive.execute("INSERT INTO ratings (input, answer, who, vote, browser, about) VALUES ('x', 'y', 'z', 1, 'b', 'declined')", []).is_err()); 2078 // The admin can join a vote to the declined record, and a comment to its vote. 2079 assert_eq!( 2080 rows(&archive, "SELECT d.declined_ms, v.about, v.vote, v.comment FROM votes_commented v JOIN declined d ON d.input = v.input AND v.about = 'declined'"), 2081 ["Real(5.0)|Text(\"declined\")|Text(\"down\")|Text(\"it is physics\")"] 2082 ); 2083 assert_eq!(rows(&archive, "SELECT about, vote_then, vote_now, latest FROM vote_comments"), ["Text(\"declined\")|Text(\"down\")|Text(\"down\")|Integer(1)"]); 2084 } 2085 2086 #[test] 2087 fn step_21_leaves_the_old_rows_readable_as_answered() { 2088 // An archive as it was at step 20, with a vote and a comment on an answer. 2089 let mut archive = rusqlite::Connection::open_in_memory().unwrap(); 2090 archive.execute("CREATE TABLE IF NOT EXISTS migrated (version INTEGER PRIMARY KEY)", []).unwrap(); 2091 let run = |archive: &mut rusqlite::Connection, steps: std::ops::Range<usize>| { 2092 for index in steps { 2093 let all = archive.transaction().unwrap(); 2094 for statement in MIGRATIONS[index] { 2095 all.execute(statement, []).unwrap_or_else(|error| panic!("step {}: {error}", index + 1)); 2096 } 2097 all.commit().unwrap(); 2098 } 2099 }; 2100 run(&mut archive, 0..20); 2101 archive.execute("INSERT INTO ratings (input, answer, who, vote, browser) VALUES ('is it?', 'abc', 'w', 1, 'b')", []).unwrap(); 2102 archive.execute("INSERT INTO comments (at_ms, input, answer, who, browser, vote, comment) VALUES (1, 'is it?', 'abc', 'w', 'b', 1, 'yes')", []).unwrap(); 2103 run(&mut archive, 20..21); 2104 assert_eq!(rows(&archive, "SELECT about, vote, comment FROM votes_commented"), ["Text(\"answered\")|Text(\"up\")|Text(\"yes\")"]); 2105 assert_eq!(rows(&archive, "SELECT about FROM vote_comments"), ["Text(\"answered\")"]); 2106 } 2107}