A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401//! The canvas app (`canvas/canvas` + `canvas/pixel`).//!//! A canvas is an ordinary node; each painted cell is a hidden child keyed//! `p_<x>_<y>`, so the unique index on `(parent_id, key)` makes a cell's identity//! the database's problem rather than ours, and repainting is an update of that//! row. The mimes mirror pairs the app already has (`vote/poll` + `vote/vote`,//! `speak/list` + `speak/speak`).//!//! Raw GraphQL rather than cynic fragments throughout: these are three small//! operations over one lean row shape, and the cost of a wire type per operation//! is not repaid here.
use super::*;
/// How many cells a canvas may be across or down.////// A cap, not a recommendation. 128x128 is 16,384 rows, which inserts in about/// three seconds and is far more than a room will ever fill; the point is that a/// mistyped number cannot ask the database for a million nodes.pub const MAX_CANVAS_SIDE: u32 = 128;
/// The colour of every painted cell of a canvas, as `((x, y), colour)`.////// Only `key` and `data` are selected. The rest of a node row (name, path,/// ancestors, timestamps, the owner relation) is dead weight multiplied by the/// number of cells, which is the whole reason this is fast.pub async fn load_canvas(access_token: Option<&str>, canvas_id: &str) -> Result<Vec<Cell>, String> { let data = execute_raw_vars( access_token, "query($p: uuid!) { \ nodes(where: {parentId: {_eq: $p}, mimeId: {_eq: \"canvas/pixel\"}}) \ { key data ownerId updatedAt } \ }", serde_json::json!({ "p": canvas_id }), ) .await?; // `execute_raw_vars` returns the `data` OBJECT, so `nodes` is at the top. let rows = data .get("nodes") .and_then(|n| n.as_array()) .cloned() .unwrap_or_default(); Ok(rows.iter().filter_map(parse_cell_full).collect())}
/// One painted cell: where it is, what colour, and who put it there.////// `owner` is the user id, not a name. Names are resolved once for the whole/// board rather than per cell — a thousand cells are usually a handful of/// people, and the id is what the row actually carries.#[derive(Debug, Clone, PartialEq)]pub struct Cell { pub at: (u32, u32), pub colour: u8, pub owner: Option<String>, /// When it was last painted, as an ISO timestamp. `updated_at` rather than /// `created_at`: a repainted cell is the new painter's, and the question the /// tooltip answers is who put THIS colour here, not who was first. pub when: Option<String>,}
/// One `{ key, data, ownerId }` row as a [`Cell`], or `None` if it is not one.////// Pure, so the wire shape can be tested without a browser or a server.pub fn parse_cell_full(row: &serde_json::Value) -> Option<Cell> { let ((x, y), colour) = parse_cell(row)?; // Missing rather than empty: a row whose owner this reader may not see is // unattributed, which is different from being painted by nobody. let text = |key: &str| { row.get(key) .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(str::to_string) }; Some(Cell { at: (x, y), colour, owner: text("ownerId"), when: text("updatedAt"), })}
/// One `{ key, data }` row as `((x, y), colour)`, or `None` if it is not a cell.////// Pure, so the wire shape can be tested without a browser or a server.pub fn parse_cell(row: &serde_json::Value) -> Option<((u32, u32), u8)> { let key = row.get("key")?.as_str()?; let colour = row.get("data")?.get("c")?.as_u64()? as u8; let (x, y) = parse_key(key)?; Some(((x, y), colour))}
/// `p_<x>_<y>` back into coordinates.pub fn parse_key(key: &str) -> Option<(u32, u32)> { let rest = key.strip_prefix("p_")?; let (x, y) = rest.split_once('_')?; Some((x.parse().ok()?, y.parse().ok()?))}
/// The key a cell is stored under.pub fn cell_key(x: u32, y: u32) -> String { format!("p_{x}_{y}")}
/// Paint one cell, creating it or repainting it.////// Update first, insert only if the cell was untouched. The obvious `on_conflict`/// upsert is not available: `(parent_id, key)` is a PARTIAL unique index/// (`where deleted_at is null`), and Postgres `on conflict on constraint` takes/// only real constraints, so Hasura answers "constraint … does not exist"./// Verified against production rather than inferred from the schema, which lists/// the index in `nodes_constraint` as though it were usable.////// A repaint is therefore one round trip and a fresh cell is two, both bounded by/// the cooldown the database enforces (`migrations/0007`).pub async fn paint_cell( access_token: Option<&str>, canvas_id: &str, context_id: &str, painter_id: &str, x: u32, y: u32, colour: u8,) -> Result<(), String> { let key = cell_key(x, y); // The repaint takes the cell over: `ownerId` as well as the colour. Hasura // presets the owner on INSERT only, so without this a repainted cell kept // the first painter's name — and the board would credit the wrong person // for a colour they did not choose. `updatedAt` follows from the trigger. let updated = execute_raw_vars_quiet( access_token, "mutation($p: uuid!, $k: String!, $d: jsonb!, $u: uuid!) { \ updateNodes(where: {parentId: {_eq: $p}, key: {_eq: $k}}, \ _set: {data: $d, ownerId: $u}) \ { affected_rows } }", serde_json::json!({ "p": canvas_id, "k": key, "d": { "c": colour }, "u": painter_id }), ) .await?; if affected_rows(&updated) > 0 { return Ok(()); } execute_raw_vars_quiet( access_token, "mutation($o: nodes_insert_input!) { insertNode(object: $o) { id } }", serde_json::json!({ "o": { "parentId": canvas_id, "contextId": context_id, "key": key, "name": "px", "mimeId": "canvas/pixel", "mutable": false, "data": { "c": colour }, }}), ) .await .map(|_| ())}
/// How many rows an `updateNodes` touched. Zero means the cell is new.fn affected_rows(data: &serde_json::Value) -> u64 { data.get("updateNodes") .and_then(|u| u.get("affected_rows")) .and_then(|a| a.as_u64()) .unwrap_or(0)}
/// When this person last painted here, as an ISO timestamp.////// The cooldown is enforced by the database, and the database's refusal does not/// reliably reach the client: Hasura puts the trigger's `retry_after_ms` in/// `extensions.internal`, which it omits outside dev mode. So the countdown is/// derived from a fact the client can always fetch instead. It also survives a/// reload, which parsing an error never could.pub async fn my_last_paint( access_token: Option<&str>, canvas_id: &str, user_id: &str,) -> Option<String> { let data = execute_raw_vars( access_token, "query($p: uuid!, $u: uuid!) { \ nodesAggregate(where: {parentId: {_eq: $p}, mimeId: {_eq: \"canvas/pixel\"}, \ ownerId: {_eq: $u}}) \ { aggregate { max { updatedAt } } } }", serde_json::json!({ "p": canvas_id, "u": user_id }), ) .await .ok()?; data.get("nodesAggregate")? .get("aggregate")? .get("max")? .get("updatedAt")? .as_str() .map(str::to_string)}
/// Create a canvas under `context_id`, granting the two permissions it needs if/// this context has never had one.////// Mirrors `create_speaker_list`: an owner may seed permissions for their own/// context, so a feature can arrive in an existing context without a migration/// touching every row in the database.////// `cooldown` is the rate limit written onto the member permission, and it is/// what makes the canvas survivable in a hall: it bounds the whole feature's/// write rate to one placement per person per interval, enforced by the trigger/// rather than by the client asking nicely.pub async fn create_canvas( access_token: Option<&str>, context_id: &str, name: &str, width: u32, height: u32, cooldown_seconds: u32,) -> Result<model::InsertedNode, String> { let width = width.clamp(1, MAX_CANVAS_SIDE); let height = height.clamp(1, MAX_CANVAS_SIDE);
let existing = node_insert_mimes(access_token, context_id).await; if !existing.iter().any(|m| m == "canvas/canvas") { execute_raw_vars( access_token, "mutation($objs: [permissions_insert_input!]!) { \ insertPermissions(objects: $objs) { affected_rows } }", serde_json::json!({ "objs": [ { "contextId": context_id, "nodeId": context_id, "mimeId": "canvas/canvas", "role": "owner", "parents": ["wiki/event", "wiki/group", "wiki/folder"], "active": true, "insert": true, "select": true, "update": true, "delete": true, }, { "contextId": context_id, "nodeId": context_id, "mimeId": "canvas/pixel", "role": "member", "parents": ["canvas/canvas"], "active": true, // No delete: a cell is repainted, never removed, so the // canvas cannot be quietly erased one cell at a time. "insert": true, "select": true, "update": true, "delete": false, "rate_limit": format!("{cooldown_seconds} seconds"), }, ] }), ) .await?; }
// And bring the limit the DATABASE enforces in step with the one this canvas // states. They are stored apart -- the trigger reads the context's // permission, the countdown reads the canvas -- and a context seeded when // the default was a minute went on enforcing a minute while every canvas // made in it since said twenty seconds. The stated cooldown has to be the // real one, or the board tells a painter to try again at a moment it will // refuse them. // // Per CONTEXT, so this moves the limit for canvases already in it. That is // the seam the permission is on, and one limit a room can rely on beats two // that disagree. execute_raw_vars( access_token, "mutation($c: uuid!, $r: interval!) { \ updatePermissions(where: {contextId: {_eq: $c}, mimeId: {_eq: \"canvas/pixel\"}}, \ _set: {rate_limit: $r}) { affected_rows } }", serde_json::json!({ "c": context_id, "r": format!("{cooldown_seconds} seconds") }), ) .await?;
insert_node_named( access_token, model::NodesInsertInput { name: Some(name.to_string()), key: None, mime_id: Some("canvas/canvas".to_string()), parent_id: Some(model::Uuid(context_id.to_string())), context_id: Some(model::Uuid(context_id.to_string())), data: Some(model::Jsonb(serde_json::json!({ "w": width, "h": height, "cooldown": cooldown_seconds, }))), ..Default::default() }, name, ) .await? .ok_or_else(|| "canvas not created".to_string())}
/// The canvas a context is currently showing, if its owner chose one.////// Stored as a `pixel` relation on the context, the same mechanism the projector/// uses for its active node: `(parent_id, name)` is unique, so choosing one/// replaces the previous choice rather than accumulating.pub async fn focused_canvas(access_token: Option<&str>, context_id: &str) -> Option<String> { let data = execute_raw_vars( access_token, "query($p: uuid!) { \ relations(where: {parentId: {_eq: $p}, name: {_eq: \"canvas\"}}) { nodeId } }", serde_json::json!({ "p": context_id }), ) .await .ok()?; data.get("relations")? .as_array()? .iter() .find_map(|r| r.get("nodeId")?.as_str().map(str::to_string))}
/// Choose the canvas this context shows, or clear it with `None`.////// Owner-only by the permission on `relations`; the relation table has a real/// unique constraint, so unlike a node this genuinely can be upserted.pub async fn set_focused_canvas( access_token: Option<&str>, context_id: &str, canvas_id: Option<&str>,) -> Result<(), String> { execute_raw_vars( access_token, "mutation($o: relations_insert_input!) { \ insertRelation(object: $o, on_conflict: { \ constraint: relations_parent_id_name_key, update_columns: [nodeId] \ }) { id } }", serde_json::json!({ "o": { "parentId": context_id, "name": "canvas", "nodeId": canvas_id, }}), ) .await .map(|_| ())}
/// Open a canvas to the room, or lock it so it takes no more paint.////// The state lives on the canvas node's `mutable`, which is what the board/// already reads to decide whether to offer itself and what draws the "Closed"/// chip. Setting it here is only half the lock: the other half is a trigger/// (`migrations/0014`) that refuses a placement on a locked canvas, so a board/// closed to the room is closed to anything that is not the board too.pub async fn set_canvas_open( access_token: Option<&str>, canvas_id: &str, open: bool,) -> Result<(), String> { execute_raw_vars( access_token, "mutation($id: uuid!, $m: Boolean!) { \ updateNode(pk_columns: {id: $id}, _set: {mutable: $m}) { id mutable } }", serde_json::json!({ "id": canvas_id, "m": open }), ) .await .map(|_| ())}
#[cfg(test)]mod tests { use super::*;
#[test] fn a_cell_key_round_trips() { assert_eq!(cell_key(0, 0), "p_0_0"); assert_eq!(parse_key(&cell_key(12, 34)), Some((12, 34))); // Anything that is not a cell is ignored rather than guessed at. assert_eq!(parse_key("p_12"), None); assert_eq!(parse_key("12_34"), None); assert_eq!(parse_key("p_x_1"), None); }
#[test] fn a_row_becomes_a_coloured_cell() { let row = serde_json::json!({"key": "p_3_4", "data": {"c": 7}}); assert_eq!(parse_cell(&row), Some(((3, 4), 7))); // A row without a colour is not a cell; better to skip it than to paint // an arbitrary one. assert_eq!(parse_cell(&serde_json::json!({"key": "p_3_4"})), None); assert_eq!(parse_cell(&serde_json::json!({"data": {"c": 1}})), None); }
/// A repaint is an update; only an untouched cell is inserted. #[test] fn a_painted_cell_is_updated_rather_than_inserted() { let updated = serde_json::json!({"updateNodes": {"affected_rows": 1}}); assert_eq!(affected_rows(&updated), 1); let missing = serde_json::json!({"updateNodes": {"affected_rows": 0}}); assert_eq!(affected_rows(&missing), 0, "zero means insert it"); // A shape we do not recognise must not be read as "already painted", or // the cell would silently never appear. assert_eq!(affected_rows(&serde_json::json!({})), 0); }
/// A mistyped size cannot ask the database for a million rows. #[test] fn a_canvas_side_is_capped() { assert_eq!(1000u32.clamp(1, MAX_CANVAS_SIDE), MAX_CANVAS_SIDE); assert_eq!(0u32.clamp(1, MAX_CANVAS_SIDE), 1); }}