···11+/// Global state management for backfill operations.
22+///
33+/// This module provides a singleton OTP actor that tracks whether a backfill
44+/// operation is currently running. The state persists across WebSocket
55+/// reconnections and page refreshes, allowing the UI to show accurate backfill
66+/// status even if the user refreshes the page during a long-running backfill.
77+///
88+/// ## Architecture
99+///
1010+/// - Single global actor instance started at server boot
1111+/// - State is shared across all client connections
1212+/// - Backfill process updates state when starting/stopping
1313+/// - UI components poll state to update their displays
1414+///
1515+/// ## Example Usage
1616+///
1717+/// ```gleam
1818+/// // Start the actor (done once at server startup)
1919+/// let assert Ok(backfill_state) = backfill_state.start()
2020+///
2121+/// // Start a backfill operation
2222+/// process.send(backfill_state, backfill_state.StartBackfill)
2323+///
2424+/// // Query current state
2525+/// let is_backfilling = actor.call(
2626+/// backfill_state,
2727+/// waiting: 100,
2828+/// sending: backfill_state.IsBackfilling,
2929+/// )
3030+///
3131+/// // Stop the backfill
3232+/// process.send(backfill_state, backfill_state.StopBackfill)
3333+/// ```
3434+import gleam/erlang/process
3535+import gleam/otp/actor
3636+3737+/// Internal state of the backfill actor
3838+pub type State {
3939+ State(backfilling: Bool)
4040+}
4141+4242+/// Messages that can be sent to the backfill state actor
4343+pub type Message {
4444+ /// Query whether a backfill is currently running.
4545+ /// The actor will send the current state to the provided Subject.
4646+ IsBackfilling(reply_with: process.Subject(Bool))
4747+ /// Set backfilling state to True (sent when backfill begins)
4848+ StartBackfill
4949+ /// Set backfilling state to False (sent when backfill completes)
5050+ StopBackfill
5151+}
5252+5353+/// Start the backfill state actor.
5454+///
5555+/// This should be called once during server initialization.
5656+/// Returns a Subject that can be used to send messages to the actor.
5757+pub fn start() -> Result(process.Subject(Message), actor.StartError) {
5858+ let result =
5959+ actor.new(State(backfilling: False))
6060+ |> actor.on_message(handle_message)
6161+ |> actor.start
6262+6363+ case result {
6464+ Ok(started) -> Ok(started.data)
6565+ Error(err) -> Error(err)
6666+ }
6767+}
6868+6969+/// Handle incoming messages to update or query the backfill state
7070+fn handle_message(state: State, message: Message) -> actor.Next(State, Message) {
7171+ case message {
7272+ IsBackfilling(client) -> {
7373+ process.send(client, state.backfilling)
7474+ actor.continue(state)
7575+ }
7676+7777+ StartBackfill -> {
7878+ actor.continue(State(backfilling: True))
7979+ }
8080+8181+ StopBackfill -> {
8282+ actor.continue(State(backfilling: False))
8383+ }
8484+ }
8585+}
+186
server/src/components/backfill_button.gleam
···11+import backfill
22+import backfill_state
33+import components/button
44+import database
55+import gleam/erlang/process
66+import gleam/json
77+import gleam/list
88+import gleam/otp/actor
99+import lustre
1010+import lustre/attribute
1111+import lustre/effect
1212+import lustre/element.{type Element}
1313+import lustre/element/html
1414+import lustre/event
1515+import sqlight
1616+1717+// APP
1818+1919+pub fn component(
2020+ db: sqlight.Connection,
2121+ backfill_state_subject: process.Subject(backfill_state.Message),
2222+) {
2323+ lustre.application(init(db, backfill_state_subject, _), update, view)
2424+}
2525+2626+// MODEL
2727+2828+pub type Model {
2929+ Model(
3030+ backfilling: Bool,
3131+ is_admin: Bool,
3232+ db: sqlight.Connection,
3333+ backfill_state: process.Subject(backfill_state.Message),
3434+ )
3535+}
3636+3737+fn init(
3838+ db: sqlight.Connection,
3939+ backfill_state_subject: process.Subject(backfill_state.Message),
4040+ flags: #(Bool, Bool),
4141+) -> #(Model, effect.Effect(Msg)) {
4242+ let #(is_admin, backfilling) = flags
4343+ let initial_effect = case backfilling {
4444+ True -> start_polling()
4545+ False -> effect.none()
4646+ }
4747+4848+ #(
4949+ Model(
5050+ backfilling: backfilling,
5151+ is_admin: is_admin,
5252+ db: db,
5353+ backfill_state: backfill_state_subject,
5454+ ),
5555+ initial_effect,
5656+ )
5757+}
5858+5959+// UPDATE
6060+6161+pub opaque type Msg {
6262+ UserClickedBackfill
6363+ CheckBackfillState
6464+}
6565+6666+fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
6767+ case msg {
6868+ UserClickedBackfill -> #(
6969+ Model(..model, backfilling: True),
7070+ effect.batch([
7171+ do_backfill(model.db, model.backfill_state),
7272+ start_polling(),
7373+ ]),
7474+ )
7575+7676+ CheckBackfillState -> {
7777+ // Query the global backfill state
7878+ let backfilling =
7979+ actor.call(
8080+ model.backfill_state,
8181+ waiting: 100,
8282+ sending: backfill_state.IsBackfilling,
8383+ )
8484+8585+ // Update model and continue polling only if still backfilling
8686+ case backfilling, model.backfilling {
8787+ // Still backfilling - continue polling
8888+ True, _ -> #(Model(..model, backfilling: True), start_polling())
8989+ // Just completed (was backfilling, now not) - emit event to reload page
9090+ False, True -> #(
9191+ Model(..model, backfilling: False),
9292+ event.emit("backfill-complete", json.null()),
9393+ )
9494+ // Was already not backfilling - do nothing
9595+ False, False -> #(model, effect.none())
9696+ }
9797+ }
9898+ }
9999+}
100100+101101+// EFFECTS
102102+103103+fn do_backfill(
104104+ db: sqlight.Connection,
105105+ backfill_state_subject: process.Subject(backfill_state.Message),
106106+) -> effect.Effect(Msg) {
107107+ effect.from(fn(_dispatch) {
108108+ // Update global state to indicate backfill is starting
109109+ process.send(backfill_state_subject, backfill_state.StartBackfill)
110110+111111+ // Spawn async process to run backfill without blocking the UI
112112+ let _ =
113113+ process.spawn_unlinked(fn() {
114114+ // Run the backfill
115115+ case database.get_record_type_lexicons(db) {
116116+ Ok(lexicons) -> {
117117+ let #(collections, external_collections) =
118118+ lexicons
119119+ |> list.partition(fn(lex) {
120120+ backfill.nsid_matches_domain_authority(lex.id)
121121+ })
122122+123123+ let collection_ids = list.map(collections, fn(lex) { lex.id })
124124+ let external_collection_ids =
125125+ list.map(external_collections, fn(lex) { lex.id })
126126+127127+ let config = backfill.default_config()
128128+129129+ // Run backfill (this will take time)
130130+ let _ =
131131+ backfill.backfill_collections(
132132+ [],
133133+ collection_ids,
134134+ external_collection_ids,
135135+ config,
136136+ db,
137137+ )
138138+139139+ // Backfill is complete, update global state
140140+ process.send(backfill_state_subject, backfill_state.StopBackfill)
141141+ }
142142+ Error(_) -> {
143143+ // No lexicons, stop backfill immediately
144144+ process.send(backfill_state_subject, backfill_state.StopBackfill)
145145+ }
146146+ }
147147+ })
148148+149149+ Nil
150150+ })
151151+}
152152+153153+fn start_polling() -> effect.Effect(Msg) {
154154+ use dispatch <- effect.from
155155+156156+ // Spawn a process that waits 2 seconds then dispatches CheckBackfillState
157157+ let _ =
158158+ process.spawn_unlinked(fn() {
159159+ process.sleep(2000)
160160+ dispatch(CheckBackfillState)
161161+ })
162162+163163+ Nil
164164+}
165165+166166+// VIEW
167167+168168+fn view(model: Model) -> Element(Msg) {
169169+ case model.is_admin {
170170+ False -> element.none()
171171+ True -> {
172172+ let button_text = case model.backfilling {
173173+ True -> "Backfilling..."
174174+ False -> "Backfill Collections"
175175+ }
176176+177177+ html.div([attribute.class("inline")], [
178178+ button.button(
179179+ disabled: model.backfilling,
180180+ on_click: UserClickedBackfill,
181181+ text: button_text,
182182+ ),
183183+ ])
184184+ }
185185+ }
186186+}
+32
server/src/components/button.gleam
···11+import lustre/attribute
22+import lustre/element.{type Element}
33+import lustre/element/html
44+import lustre/event
55+66+/// Standard button styling used throughout the app
77+const button_classes = "font-mono px-4 py-2 text-sm text-zinc-300 bg-zinc-800 hover:bg-zinc-700 rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800"
88+99+/// Render a button with standard styling
1010+pub fn button(
1111+ disabled disabled: Bool,
1212+ on_click on_click: msg,
1313+ text text: String,
1414+) -> Element(msg) {
1515+ html.button(
1616+ [
1717+ attribute.type_("button"),
1818+ attribute.class(button_classes),
1919+ attribute.disabled(disabled),
2020+ event.on_click(on_click),
2121+ ],
2222+ [html.text(text)],
2323+ )
2424+}
2525+2626+/// Render a link styled as a button
2727+pub fn link(href href: String, text text: String) -> Element(msg) {
2828+ html.a(
2929+ [attribute.href(href), attribute.class(button_classes)],
3030+ [html.text(text)],
3131+ )
3232+}
···606606 }
607607}
608608609609+/// Gets the total number of records in the database
610610+pub fn get_record_count(conn: sqlight.Connection) -> Result(Int, sqlight.Error) {
611611+ let sql =
612612+ "
613613+ SELECT COUNT(*) as count
614614+ FROM record
615615+ "
616616+617617+ let decoder = {
618618+ use count <- decode.field(0, decode.int)
619619+ decode.success(count)
620620+ }
621621+622622+ case sqlight.query(sql, on: conn, with: [], expecting: decoder) {
623623+ Ok([count]) -> Ok(count)
624624+ Ok(_) -> Ok(0)
625625+ Error(err) -> Error(err)
626626+ }
627627+}
628628+609629/// Checks if a lexicon exists for a given collection NSID
610630/// First checks the dedicated lexicon table, then falls back to record table
611631pub fn has_lexicon_for_collection(
···780800 use json <- decode.field(1, decode.string)
781801 use created_at <- decode.field(2, decode.string)
782802 decode.success(Lexicon(id:, json:, created_at:))
803803+ }
804804+805805+ sqlight.query(sql, on: conn, with: [], expecting: decoder)
806806+}
807807+808808+pub type ActivityPoint {
809809+ ActivityPoint(timestamp: String, count: Int)
810810+}
811811+812812+/// Gets record indexing activity over time
813813+/// Returns hourly counts for the specified duration
814814+pub fn get_record_activity(
815815+ conn: sqlight.Connection,
816816+ duration_hours: Int,
817817+) -> Result(List(ActivityPoint), sqlight.Error) {
818818+ // SQLite datetime calculation for cutoff time
819819+ let sql =
820820+ "
821821+ WITH RECURSIVE time_series AS (
822822+ SELECT datetime('now', '-" <> int.to_string(duration_hours) <> " hours') AS bucket
823823+ UNION ALL
824824+ SELECT datetime(bucket, '+1 hour')
825825+ FROM time_series
826826+ WHERE bucket < datetime('now')
827827+ )
828828+ SELECT
829829+ strftime('%Y-%m-%dT%H:00:00Z', ts.bucket) as timestamp,
830830+ COALESCE(COUNT(r.uri), 0) as count
831831+ FROM time_series ts
832832+ LEFT JOIN record r ON
833833+ datetime(r.indexed_at) >= datetime(ts.bucket)
834834+ AND datetime(r.indexed_at) < datetime(ts.bucket, '+1 hour')
835835+ AND datetime(r.indexed_at) >= datetime('now', '-" <> int.to_string(duration_hours) <> " hours')
836836+ GROUP BY ts.bucket
837837+ ORDER BY ts.bucket ASC
838838+ "
839839+840840+ let decoder = {
841841+ use timestamp <- decode.field(0, decode.string)
842842+ use count <- decode.field(1, decode.int)
843843+ decode.success(ActivityPoint(timestamp:, count:))
783844 }
784845785846 sqlight.query(sql, on: conn, with: [], expecting: decoder)
+42
server/src/format.gleam
···11+import gleam/int
22+import gleam/list
33+import gleam/string
44+55+/// Formats an integer with comma thousand separators
66+/// Example: format_number(1234567) -> "1,234,567"
77+pub fn format_number(num: Int) -> String {
88+ let str = int.to_string(num)
99+ let is_negative = string.starts_with(str, "-")
1010+ let digits = case is_negative {
1111+ True -> string.drop_start(str, 1)
1212+ False -> str
1313+ }
1414+1515+ let chars = string.to_graphemes(digits)
1616+ let char_count = list.length(chars)
1717+1818+ // If 3 or fewer digits, no commas needed
1919+ case char_count <= 3 {
2020+ True -> str
2121+ False -> {
2222+ let reversed = list.reverse(chars)
2323+2424+ let formatted =
2525+ reversed
2626+ |> list.index_map(fn(char, idx) {
2727+ case idx > 0 && idx % 3 == 0 {
2828+ True -> [",", char]
2929+ False -> [char]
3030+ }
3131+ })
3232+ |> list.flatten
3333+ |> list.reverse
3434+ |> string.join("")
3535+3636+ case is_negative {
3737+ True -> "-" <> formatted
3838+ False -> formatted
3939+ }
4040+ }
4141+ }
4242+}