From b774f923645de71281011f0791700d411cbf3def Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Sun, 11 Oct 2026 01:47:40 +0200 Subject: [PATCH 1/4] Mention edges readable over the engine seam, both directions (nxf 6j6v.q29p) - Engine::mentions(id): the outgoing targets, the existing read::mentions put on the seam. - Engine::mentioned_by(id, &MentionedByQuery) / mentioned_by_value: the live items that mention a target, in id order, filtered by NextFilter (type, container, label) and cut/paged exactly like next_query. - read::mentioners_of(store, targets): THE bulk reverse lookup (over the new Store::mentioned_by_bulk) that the derived ranking key count = "mentioned_by" (x0wf) is to share, per docs/specs/derived-ranking-keys.md section 11. - The paging half of next_page is factored into page_through/Paging/Watermark, so next and mentioned_by share one snapshot table, expiry, caps and invalidation; the snapshot key names the read, so tokens do not cross between them. next's keys are unchanged. - CLI: nxf mention by [--type]... [--in] [--label] [--limit [--paginate [--token]]], with list records and next's --json envelope; goldens under both plugins, guide EN/DE, generated docs, verb-seam alias. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME --- changes/q29p-mention-reads.md | 25 ++ crates/cli/docs/guide/de/commands.md | 20 +- crates/cli/docs/guide/en/commands.md | 20 +- crates/cli/src/commands/mod.rs | 63 ++++ crates/cli/src/lib.rs | 58 ++- .../golden/issue-tracker/workflow.trycmd | 14 + .../golden/personal-todo/workflow.trycmd | 14 + crates/cli/tests/mention.rs | 197 +++++++++- crates/cli/tests/verb_seam.rs | 7 + crates/core/src/next_cache.rs | 8 +- crates/core/src/store.rs | 52 +++ crates/facade/src/engine.rs | 35 +- crates/facade/src/read.rs | 251 ++++++++++++- crates/facade/tests/mentioned_by.rs | 352 ++++++++++++++++++ docs/generated/cli.md | 27 +- docs/generated/commands.json | 65 +++- docs/generated/man/nxf-mention-by.1 | 34 ++ docs/generated/man/nxf-mention.1 | 7 +- docs/generated/man/nxf.1 | 2 +- 19 files changed, 1218 insertions(+), 33 deletions(-) create mode 100644 changes/q29p-mention-reads.md create mode 100644 crates/facade/tests/mentioned_by.rs create mode 100644 docs/generated/man/nxf-mention-by.1 diff --git a/changes/q29p-mention-reads.md b/changes/q29p-mention-reads.md new file mode 100644 index 0000000..cbbc037 --- /dev/null +++ b/changes/q29p-mention-reads.md @@ -0,0 +1,25 @@ +--- +type: added +facade: changed +--- +[en] +Mentions can now be read in both directions. `nxf mention by ` lists the items that mention an +item, in id order, as `list` records (closed and archived mentioners included). `--type`, `--in` and +`--label` narrow the list as they narrow `next`. `--limit` cuts it and says so, and `--paginate` +pages through it with a token, exactly as `next` does. Under `--json` you get a plain array, or +with `--limit` the `{"items", "total"}` envelope, plus `next_token` and `restarted` with +`--paginate`. An embedding app gets the same reads from the engine: `Engine::mentions(id)` returns +an item's outgoing targets, and `Engine::mentioned_by(id, &MentionedByQuery)` / +`Engine::mentioned_by_value` return the items that mention it. `read::mentioners_of` is the bulk +reverse lookup behind them. Everything is additive; no existing signature changes. +[de] +Erwähnungen lassen sich jetzt in beide Richtungen lesen. `nxf mention by ` listet die Items, die +ein Item erwähnen, in Id-Reihenfolge und als `list`-Datensätze (geschlossene und archivierte +eingeschlossen). `--type`, `--in` und `--label` grenzen die Liste ein wie bei `next`. `--limit` +kürzt sie und sagt das an, und `--paginate` blättert mit einem Token durch sie, genau wie bei +`next`. Mit `--json` kommt ein schlichtes Array, mit `--limit` die Hülle `{"items", "total"}`, und +mit `--paginate` zusätzlich `next_token` und `restarted`. Eine einbettende App bekommt dieselben +Lesezugriffe von der Engine: `Engine::mentions(id)` liefert die ausgehenden Ziele eines Items, +`Engine::mentioned_by(id, &MentionedByQuery)` und `Engine::mentioned_by_value` die Items, die es +erwähnen. `read::mentioners_of` ist die Massen-Rückwärtssuche dahinter. Alles ist additiv; keine +bestehende Signatur ändert sich. diff --git a/crates/cli/docs/guide/de/commands.md b/crates/cli/docs/guide/de/commands.md index 04d2340..b9be7b1 100644 --- a/crates/cli/docs/guide/de/commands.md +++ b/crates/cli/docs/guide/de/commands.md @@ -118,7 +118,7 @@ $ nxf dep remove ab12.0002 ab12.0003 --json ``` `mention add` / `mention list` / `mention remove` halten Freitext-Referenzen per Short-id fest — -ein Verweis, der nie blockiert: +ein Verweis, der nie blockiert. `mention by` liest sie in der Gegenrichtung: ```console $ nxf mention add ab12.0002 ab12.0001 --json @@ -132,6 +132,24 @@ $ nxf mention list ab12.0002 --json ``` +`mention by ` listet die Items, die `` erwähnen, in Id-Reihenfolge als `list --json`-Datensätze +(geschlossene und archivierte eingeschlossen). `--type`, `--in` und `--label` grenzen ein wie bei +`next`. Ohne `--limit` ist die Antwort ein schlichtes Array; mit `--limit` ist sie `{"items": [...], +"total": }`, und `--paginate` ergänzt `next_token` (null auf der letzten Seite) und `restarted` +(true, wenn ein veraltetes Token wieder die erste Seite lieferte) — genau wie `next --limit --json`: + +```console +$ nxf mention by ab12.0001 --json +[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}] + +``` + +```console +$ nxf mention by ab12.0001 --type feature --limit 1 --paginate --json +{"items":[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}],"next_token":null,"restarted":false,"total":1} + +``` + ```console $ nxf mention remove ab12.0002 ab12.0001 --json {"msg":"removed mention ab12.0002 -> ab12.0001","ok":true} diff --git a/crates/cli/docs/guide/en/commands.md b/crates/cli/docs/guide/en/commands.md index 5cce6db..7a382fc 100644 --- a/crates/cli/docs/guide/en/commands.md +++ b/crates/cli/docs/guide/en/commands.md @@ -155,7 +155,7 @@ $ nxf dep remove ab12.0002 ab12.0003 --json ``` `mention add` / `mention list` / `mention remove` record free-text short-id references — a -citation that never blocks: +citation that never blocks. `mention by` reads them the other way round: ```console $ nxf mention add ab12.0002 ab12.0001 --json @@ -169,6 +169,24 @@ $ nxf mention list ab12.0002 --json ``` +`mention by ` lists the items that mention ``, in id order, as `list --json` records +(closed and archived mentioners included). `--type`, `--in` and `--label` narrow it as they narrow +`next`. Without `--limit` the answer is a plain array; with `--limit` it is `{"items": [...], +"total": }`, and `--paginate` adds `next_token` (null on the last page) and `restarted` (true +when a stale token answered with the first page again), exactly as `next --limit --json`: + +```console +$ nxf mention by ab12.0001 --json +[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}] + +``` + +```console +$ nxf mention by ab12.0001 --type feature --limit 1 --paginate --json +{"items":[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}],"next_token":null,"restarted":false,"total":1} + +``` + ```console $ nxf mention remove ab12.0002 ab12.0001 --json {"msg":"removed mention ab12.0002 -> ab12.0001","ok":true} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 4fbfae2..6a78604 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -1999,6 +1999,69 @@ pub fn mention_list(json: bool, db: Option<&str>, id: &str) -> Result<()> { Ok(()) } +/// `nxf mention by`'s arguments, as clap parsed them. +pub struct MentionByArgs<'a> { + pub types: &'a [String], + pub within: Option<&'a str>, + pub label: Option<&'a str>, + pub limit: Option, + pub paginate: bool, + pub token: Option<&'a str>, +} + +/// List the items that mention `id` (6j6v.q29p) — through the function `Engine::mentioned_by` +/// answers from (`read::mentioned_by_page`), so the filters and the paging cannot drift apart +/// between the two, nor from `next`'s. +pub fn mention_by(json: bool, db: Option<&str>, id: &str, args: MentionByArgs<'_>) -> Result<()> { + let (ws, cfg) = open(db)?; + let store = ws.open_store()?; + let id = ws.replica.resolve_id(id); + let mut filter = read::NextFilter::new(); + for ty in args.types { + filter = filter.with_type(ty.as_str()); + } + if let Some(c) = args.within { + filter = filter.with_container(ws.replica.resolve_id(c)); + } + if let Some(label) = args.label { + filter = filter.with_label(label); + } + let mut query = read::MentionedByQuery::new().filter(filter); + if let Some(n) = args.limit { + query = query.limit(n); + } + if args.paginate { + query = query.paginate(); + } + if let Some(token) = args.token { + query = query.token(token); + } + let page = read::mentioned_by_page(&store, &id, &query)?; + if json { + let value = with_presentation_labels( + &cfg, + read::list_to_value_with_custom(&cfg, &store, &page.items)?, + ); + // The shape follows the FLAG, as for `next`: a plain array without `--limit`, the envelope + // with it. + if args.limit.is_some() { + println!("{}", read::next_page_envelope(value, &page, args.paginate)); + } else { + println!("{value}"); + } + } else { + let theme = nxs_ui::Theme::plain(); + if let Some(notice) = next_truncation_notice(&page, &theme) { + print!("{notice}"); + } + print!("{}", render_list(&cfg, &ws.replica.prefix, &page.items)); + if let Some(line) = next_token_line(&page, &theme) { + print!("{line}"); + } + } + Ok(()) +} + // ---- thread links (nxf 6j6v.8dbe) ------------------------------------------ /// Parse a `--relation` value, or a `validation` error listing the accepted tokens — the same loud diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c5c0f5e..44c73ab 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -329,7 +329,7 @@ enum Command { #[command(subcommand)] action: DepAction, }, - /// Add, remove, or list reference edges (free-text short-id citations). + /// Add, remove, or list reference edges (free-text short-id citations), in either direction. Mention { #[command(subcommand)] action: MentionAction, @@ -462,6 +462,41 @@ enum MentionAction { Remove { from: String, to: String }, /// List the short-ids `id` mentions. List { id: String }, + /// List the items that mention `id` — the reverse of `list` — in id order, closed and archived + /// mentioners included. `--type`, `--in` and `--label` narrow it as they narrow `next`; + /// `--limit` shows only the head and says so; `--paginate` adds a token for the next page. + /// Under `--json` the records are `list --json`'s, as a plain array — or, with `--limit`, as + /// `{"items": [...], "total": }`, plus `"next_token"` (a string, or null on the last page) + /// and `"restarted"` (bool) with `--paginate`, exactly as `next --limit --json` wraps them. + By { + /// The mentioned item. + id: String, + /// Only mentioners of this type (the stored type, as `list --type` takes it). Repeatable: + /// the types are alternatives. + #[arg(long = "type", value_name = "TYPE")] + ty: Vec, + /// Only mentioners in this container: its children (whose parent it is) and the items that + /// contribute to it. + #[arg(long = "in", value_name = "ID")] + within: Option, + /// Only mentioners carrying this label. + #[arg(long)] + label: Option, + /// Max items to show (default: no limit). Applied after the filters and never silently: + /// a truncated list is headed `showing of `, and under `--json` the flag wraps + /// the records as `{"items": [...], "total": }`. + #[arg(long)] + limit: Option, + /// Page through the list `--limit` rows at a time: the answer ends with a token for the + /// next page (`next_token` and `restarted` under `--json`). The list is cached on this + /// machine for an hour; a change to an item in it, or a stale or invalid token, restarts + /// at the first page. + #[arg(long, requires = "limit")] + paginate: bool, + /// The token a previous `--paginate` answer printed: show the page after that one. + #[arg(long, value_name = "TOKEN", requires = "paginate")] + token: Option, + }, } #[derive(Subcommand, Debug)] @@ -707,6 +742,27 @@ fn dispatch(cli: &Cli) -> Result<()> { MentionAction::Add { from, to } => commands::mention_add(cli.json, db, from, to), MentionAction::Remove { from, to } => commands::mention_remove(cli.json, db, from, to), MentionAction::List { id } => commands::mention_list(cli.json, db, id), + MentionAction::By { + id, + ty, + within, + label, + limit, + paginate, + token, + } => commands::mention_by( + cli.json, + db, + id, + commands::MentionByArgs { + types: ty, + within: within.as_deref(), + label: label.as_deref(), + limit: *limit, + paginate: *paginate, + token: token.as_deref(), + }, + ), }, Command::Thread { action } => match action { ThreadAction::Link { diff --git a/crates/cli/tests/golden/issue-tracker/workflow.trycmd b/crates/cli/tests/golden/issue-tracker/workflow.trycmd index 3d8a9f3..09227ad 100644 --- a/crates/cli/tests/golden/issue-tracker/workflow.trycmd +++ b/crates/cli/tests/golden/issue-tracker/workflow.trycmd @@ -125,6 +125,20 @@ $ nxf mention list ab12.0002 --json ``` +The reverse read: the items that mention the epic — `list` records, narrowed and paged like `next`: + +```console +$ nxf mention by ab12.0001 --json +[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}] + +``` + +```console +$ nxf mention by ab12.0001 --type feature --limit 1 --paginate --json +{"items":[{"archived":null,"assignee":"dev","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Build the command-line tool","design":"Thin CLI over the core.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"P1","status":"in_progress","title":"Write the CLI","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}],"next_token":null,"restarted":false,"total":1} + +``` + ```console $ nxf mention remove ab12.0002 ab12.0001 --json {"msg":"removed mention ab12.0002 -> ab12.0001","ok":true} diff --git a/crates/cli/tests/golden/personal-todo/workflow.trycmd b/crates/cli/tests/golden/personal-todo/workflow.trycmd index 4a08c2c..6601d34 100644 --- a/crates/cli/tests/golden/personal-todo/workflow.trycmd +++ b/crates/cli/tests/golden/personal-todo/workflow.trycmd @@ -125,6 +125,20 @@ $ nxf mention list ab12.0002 --json ``` +The reverse read: the items that mention the project — `list` records, narrowed and paged like `next`: + +```console +$ nxf mention by ab12.0001 --json +[{"archived":null,"assignee":"me","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Reserve outbound and return","design":"Compare fares first.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"soon","status":"in_progress","title":"Book flights","type":"todo","type_label":"todo","updated_at":"2026-06-23T00:00:00Z"}] + +``` + +```console +$ nxf mention by ab12.0001 --type todo --limit 1 --paginate --json +{"items":[{"archived":null,"assignee":"me","belongs_to":"ab12.0001","closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Reserve outbound and return","design":"Compare fares first.","due":"2026-12-31","id":"ab12.0002","priority":"1","priority_label":"soon","status":"in_progress","title":"Book flights","type":"todo","type_label":"todo","updated_at":"2026-06-23T00:00:00Z"}],"next_token":null,"restarted":false,"total":1} + +``` + ```console $ nxf mention remove ab12.0002 ab12.0001 --json {"msg":"removed mention ab12.0002 -> ab12.0001","ok":true} diff --git a/crates/cli/tests/mention.rs b/crates/cli/tests/mention.rs index 2a7d75d..de34e8c 100644 --- a/crates/cli/tests/mention.rs +++ b/crates/cli/tests/mention.rs @@ -7,7 +7,8 @@ use tempfile::TempDir; fn nxf(dir: &Path) -> Command { let mut c = nxs_test_support::cargo_bin("nxf"); - c.current_dir(dir); + // Sequential ids, so creation order is id order (the order `mention by` answers in). + c.current_dir(dir).env("NXF_DETERMINISTIC_IDS", "1"); c } @@ -76,3 +77,197 @@ fn mention_add_records_a_reference_that_does_not_block() { "mention does not block: {ready:?}" ); } + +// ---- mention by: the reverse read (6j6v.q29p) ------------------------------- + +fn create_typed(dir: &Path, ty: &str, title: &str, extra: &[&str]) -> String { + let mut args = vec![ + "create", + "--type", + ty, + "--title", + title, + "--description", + "d", + "--priority", + "P1", + "--json", + ]; + args.extend_from_slice(extra); + run_json(dir, &args)["id"].as_str().unwrap().to_string() +} + +fn ids(v: &serde_json::Value) -> Vec { + v.as_array() + .expect("an array of records") + .iter() + .map(|r| r["id"].as_str().unwrap().to_string()) + .collect() +} + +/// A target and four mentioners: a feature in the epic, two bugs (one contributing to the epic), +/// and a feature outside it. Returns `(target, epic, [mentioners in id order])`. +fn mentioned_board(dir: &Path) -> (String, String, Vec) { + nxf(dir) + .args(["init", "--plugin", "issue-tracker"]) + .assert() + .success(); + let target = create_typed(dir, "feature", "the cited one", &[]); + let epic = create_typed(dir, "epic", "the container", &[]); + let m = vec![ + create_typed(dir, "feature", "in the epic", &["--parent", &epic]), + create_typed(dir, "bug", "contributes", &[]), + create_typed(dir, "bug", "loose bug", &[]), + create_typed(dir, "feature", "loose feature", &[]), + ]; + run_json(dir, &["contributes", "add", &m[1], &epic, "--json"]); + for from in &m { + run_json(dir, &["mention", "add", from, &target, "--json"]); + } + (target, epic, m) +} + +#[test] +fn mention_by_lists_the_mentioners_as_list_records_and_filters_like_next() { + let tmp = TempDir::new().unwrap(); + let (target, epic, m) = mentioned_board(tmp.path()); + + let all = run_json(tmp.path(), &["mention", "by", &target, "--json"]); + assert_eq!( + ids(&all), + m, + "every mentioner, in id order, as a bare array" + ); + assert_eq!(all[0]["type"], "feature", "a list record"); + assert!( + all[0]["type_label"].is_string(), + "with list's presentation labels" + ); + + let bugs = run_json( + tmp.path(), + &["mention", "by", &target, "--type", "bug", "--json"], + ); + assert_eq!(ids(&bugs), m[1..3]); + let in_epic = run_json( + tmp.path(), + &["mention", "by", &target, "--in", &epic, "--json"], + ); + assert_eq!( + ids(&in_epic), + [m[0].clone(), m[1].clone()], + "child or contributor" + ); + let nothing = run_json(tmp.path(), &["mention", "by", &epic, "--json"]); + assert_eq!(nothing, serde_json::json!([])); +} + +#[test] +fn mention_by_pages_with_the_next_envelope() { + let tmp = TempDir::new().unwrap(); + let (target, _, m) = mentioned_board(tmp.path()); + + let cut = run_json( + tmp.path(), + &["mention", "by", &target, "--limit", "3", "--json"], + ); + assert_eq!(ids(&cut["items"]), m[0..3]); + assert_eq!(cut["total"], 4); + assert!( + cut.get("next_token").is_none(), + "no token without --paginate" + ); + + let p1 = run_json( + tmp.path(), + &[ + "mention", + "by", + &target, + "--limit", + "3", + "--paginate", + "--json", + ], + ); + assert_eq!(ids(&p1["items"]), m[0..3]); + assert_eq!(p1["restarted"], false); + let token = p1["next_token"] + .as_str() + .expect("a row remains") + .to_string(); + let p2 = run_json( + tmp.path(), + &[ + "mention", + "by", + &target, + "--limit", + "3", + "--paginate", + "--token", + &token, + "--json", + ], + ); + assert_eq!(ids(&p2["items"]), m[3..]); + assert_eq!(p2["next_token"], serde_json::Value::Null, "the last page"); + + // A stale token: a removed mention names a cached item, so the next answer restarts. + run_json(tmp.path(), &["mention", "remove", &m[3], &target, "--json"]); + let again = run_json( + tmp.path(), + &[ + "mention", + "by", + &target, + "--limit", + "3", + "--paginate", + "--token", + &token, + "--json", + ], + ); + assert_eq!(again["restarted"], true); + assert_eq!(ids(&again["items"]), m[0..3]); + assert_eq!(again["total"], 3); +} + +#[test] +fn mention_by_human_view_discloses_the_cut_and_prints_the_token() { + let tmp = TempDir::new().unwrap(); + let (target, _, _) = mentioned_board(tmp.path()); + let out = nxf(tmp.path()) + .args(["mention", "by", &target, "--limit", "2", "--paginate"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + // Trimmed: the CLI frames a human answer with blank lines (`output_framing.rs`). + let out = String::from_utf8(out).unwrap().trim().to_string(); + assert!(out.starts_with("showing 2 of 4\n"), "{out}"); + assert_eq!(out.lines().count(), 4, "notice, two rows, token: {out}"); + assert!( + out.lines() + .last() + .unwrap() + .starts_with("next page: --paginate --token "), + "{out}" + ); +} + +#[test] +fn mention_by_a_missing_target_fails_and_paginate_needs_a_limit() { + let tmp = TempDir::new().unwrap(); + let (target, _, _) = mentioned_board(tmp.path()); + nxf(tmp.path()) + .args(["mention", "by", "ab12.zzzz", "--json"]) + .assert() + .failure(); + nxf(tmp.path()) + .args(["mention", "by", &target, "--paginate"]) + .assert() + .failure(); +} diff --git a/crates/cli/tests/verb_seam.rs b/crates/cli/tests/verb_seam.rs index 04674d9..450d363 100644 --- a/crates/cli/tests/verb_seam.rs +++ b/crates/cli/tests/verb_seam.rs @@ -73,6 +73,13 @@ const WAIVERS: &[Waiver] = &[ seam: "mentions", reason: "`read::mentions` is what the verb body calls; the CLI only renders it.", }, + Waiver::Alias { + verb: "mention by", + seam: "mentioned_by", + reason: "`Engine::mentioned_by` (over `read::mentioned_by_page`, which the verb body \ + calls) — the seam names the relation, the CLI the direction beside `mention \ + list` (6j6v.q29p).", + }, Waiver::Alias { verb: "note list", seam: "notes", diff --git a/crates/core/src/next_cache.rs b/crates/core/src/next_cache.rs index 2ffd2b9..dd11751 100644 --- a/crates/core/src/next_cache.rs +++ b/crates/core/src/next_cache.rs @@ -1,6 +1,8 @@ -//! The snapshot cache behind `next --paginate` (6j6v.15ed): the ordered result a first page was -//! cut from, kept so the following pages come from the SAME list instead of a list that moved -//! underneath the reader. +//! The snapshot cache behind `next --paginate` (6j6v.15ed) and `mention by --paginate` +//! (6j6v.q29p): the ordered result a first page was cut from, kept so the following pages come from +//! the SAME list instead of a list that moved underneath the reader. The table keeps its first +//! reader's name; every paged read shares it, its expiry and its caps, and tells its snapshots +//! apart by the `query` key, which names the read. //! //! **Machine-local.** It lives in the `next_page_cache` table of the workspace db. The table is not //! a view and is folded from nothing: no op writes it, no snapshot image carries it, no sync moves diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index 7f408dc..3c7a0d8 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -1068,6 +1068,37 @@ impl Store { Ok(out) } + /// The present `mentions` SOURCES of every id in `targets`: for each target, the items that + /// mention it, sorted and distinct — the reverse of [`mentions_of`](Self::mentions_of), in the + /// [`contributes_to_of_bulk`](Self::contributes_to_of_bulk) shape (one query per 500 ids, over + /// the `edge_adds_to` index). A target nothing mentions is absent from the map. The sources are + /// edge endpoints, not checked against the items: the facade's `read::mentioners_of` resolves + /// them and drops the deleted ones (6j6v.q29p). + pub fn mentioned_by_bulk( + &self, + targets: &[&str], + ) -> rusqlite::Result>> { + const CHUNK: usize = 500; + let mut out: BTreeMap> = BTreeMap::new(); + for chunk in targets.chunks(CHUNK) { + let placeholders = vec!["?"; chunk.len()].join(","); + let sql = format!( + "SELECT to_id, from_id FROM present_edges \ + WHERE kind = 'mentions' AND to_id IN ({placeholders}) \ + ORDER BY to_id, from_id" + ); + let mut stmt = self.conn().prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + for row in rows { + let (to, from) = row?; + out.entry(to).or_default().push(from); + } + } + Ok(out) + } + // ---- plugin custom fields (6j6v.ekf5, T4): the read side of the folded `custom_fields` view -- /// The set custom-field values of ONE item (plugin-custom-fields §2.3), as `field → value` @@ -3761,6 +3792,27 @@ mod tests { assert!(crate::derive::blocked(s.connection()).unwrap().is_empty()); } + #[test] + fn mentioned_by_bulk_inverts_the_present_mentions_edges_per_target() { + // 6j6v.q29p: the reverse of `mentions_of`, for many targets in one read. + let mut s = Store::open_in_memory(1); + for id in ["c1.A", "c1.B", "c1.C", "c1.D", "c1.T", "c1.U"] { + s.create_item(id, "task", id, "x"); + } + s.add_edge("c1.C", "c1.T", EdgeKind::Mentions, "x"); + s.add_edge("c1.A", "c1.T", EdgeKind::Mentions, "x"); + s.add_edge("c1.A", "c1.T", EdgeKind::Mentions, "x"); // a second add: still one mentioner + s.add_edge("c1.B", "c1.U", EdgeKind::Mentions, "x"); + s.add_edge("c1.D", "c1.T", EdgeKind::Mentions, "x"); + s.remove_edge("c1.D", "c1.T", EdgeKind::Mentions, "x"); // removed: no longer a mentioner + s.add_edge("c1.B", "c1.T", EdgeKind::Dep, "x"); // another kind: not a mention + let m = s.mentioned_by_bulk(&["c1.T", "c1.U", "c1.A"]).unwrap(); + assert_eq!(m["c1.T"], ["c1.A", "c1.C"], "sorted and distinct"); + assert_eq!(m["c1.U"], ["c1.B"]); + assert!(!m.contains_key("c1.A"), "nothing mentions it: absent"); + assert!(s.mentioned_by_bulk(&[]).unwrap().is_empty()); + } + #[test] fn remap_prefix_swaps_only_the_prefix_across_ops_views_and_freetext() { let mut s = Store::open_in_memory(1); diff --git a/crates/facade/src/engine.rs b/crates/facade/src/engine.rs index a4f5221..8cd0a9c 100644 --- a/crates/facade/src/engine.rs +++ b/crates/facade/src/engine.rs @@ -11,7 +11,9 @@ use crate::error::Result; use crate::plugin::{self, PluginConfig}; -use crate::read::{self, BlockedItem, NextPage, NextQuery, PrimeReport, ShowRecord}; +use crate::read::{ + self, BlockedItem, MentionedByQuery, NextPage, NextQuery, PrimeReport, ShowRecord, +}; use crate::validate; use crate::workspace::{Workspace, WorkspaceExt}; use crate::write::{self, NewItem}; @@ -265,6 +267,37 @@ impl Engine { .with_state_mut(|s| write::mention_remove(&mut s.store, now, actor, from, to)) } + /// The ids `id` mentions — its outgoing reference edges, sorted (6j6v.q29p): the read + /// [`mention_add`](Engine::mention_add) writes, and what the CLI's `mention list` prints. + /// `not_found` if the item is missing/deleted, `io` on a db fault. + pub fn mentions(&self, id: &str) -> Result> { + self.handle.try_with_state(|s| read::mentions(&s.store, id)) + } + + /// The items that mention `id` (6j6v.q29p): the live mentioners — closed and archived included, + /// deleted ones not — in id order, narrowed by `query`'s filter (type, container, label, as for + /// [`next_query`](Engine::next_query)) and cut or paged exactly as `next_query` pages: the + /// same `total`/`start`/`next_token`/`restarted`, the same machine-local `next_page_cache` + /// (so the same db write another handle's [`subscribe`](Engine::subscribe) sees), the same + /// expiry and invalidation. The CLI's `mention by`. `not_found` if `id` is missing/deleted; + /// `validation` for `paginate` without a non-zero `limit`, or a token without `paginate`. + pub fn mentioned_by(&self, id: &str, query: &MentionedByQuery) -> Result { + self.handle + .try_with_state(|s| read::mentioned_by_page(&s.store, id, query)) + } + + /// [`mentioned_by`](Engine::mentioned_by) as JSON: `{"items", "total"}`, plus `next_token` + /// and `restarted` when the query paginates — `items` being the records + /// [`list_value`](Engine::list_value) gives. The envelope the CLI's + /// `mention by --limit --json` prints. + pub fn mentioned_by_value(&self, id: &str, query: &MentionedByQuery) -> Result { + self.handle.try_with_state(|s| { + let page = read::mentioned_by_page(&s.store, id, query)?; + let items = read::list_to_value_with_custom(&self.cfg, &s.store, &page.items)?; + Ok(read::next_page_envelope(items, &page, query.paginate)) + }) + } + /// Record a `from -> to` contributes-to edge (n:m; never blocks). pub fn contributes_add(&self, now: &str, actor: &str, from: &str, to: &str) -> Result<()> { self.handle diff --git a/crates/facade/src/read.rs b/crates/facade/src/read.rs index fab6ee3..2090e27 100644 --- a/crates/facade/src/read.rs +++ b/crates/facade/src/read.rs @@ -27,7 +27,7 @@ use nexus_flow_core::graph::Board; use nexus_flow_core::model::{ItemRow, LinkWeight, ThreadLink}; use nexus_flow_core::store::Store; use serde_json::{json, Value}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; // ---- schema (plugin-aware field + type-system introspection, 8qv.5 / y5j8) -------------------- // @@ -865,7 +865,8 @@ fn next_lane( items } -/// One page of a `next` result: the rows to show, plus how many there were before the cut. +/// One page of a `next` result — or of any read paged like it (`mentioned_by`, 6j6v.q29p): the +/// rows to show, plus how many there were before the cut. /// /// The type exists so the total cannot be dropped on the way to a renderer. A limit that cuts /// silently is worse than no limit at all — a list that shows 15 of 180 and looks like 15 of 15 @@ -1273,24 +1274,74 @@ pub fn next_page(cfg: &PluginConfig, store: &Store, q: &NextQuery) -> Result Result { crate::validate::iso_date(&q.now)?; - if q.token.is_some() && !q.paginate { - return Err(NxfError::validation("a page token needs paginate")); - } - let limit = match (q.paginate, q.limit) { - (false, _) => None, - (true, Some(n)) if n > 0 => Some(n), - (true, _) => return Err(NxfError::validation("paginate needs a limit of at least 1")), - }; + let paging = Paging::check(q.limit, q.paginate, q.token.as_deref())?; // The watermark BEFORE the list: an op that lands while the list is computed is then above it // and counts as "since" — a spurious restart at worst, never a missed one. - let watermark = store.ops_watermark()?; - let watermark_op = store.op_at(watermark)?; + let mark = Watermark::read(store)?; let fresh = next_filtered(cfg, store, &q.now, q.sort, &q.filter)?; - let Some(n) = limit else { - return Ok(truncate_next(fresh, q.limit)); + page_through(store, fresh, || q.key(cfg), &paging, mark, clock) +} + +/// The paging half of a request, checked: what [`page_through`] cuts and caches by. Shared by +/// every paged read (`next`, `mentioned_by`), so their rules cannot drift apart (6j6v.q29p). +struct Paging<'a> { + /// The cut: `None` is the whole list. + limit: Option, + /// `Some(n)` when the read hands out tokens, `n` its page size (never 0). + page: Option, + /// The token of a previous page. + token: Option<&'a str>, +} + +impl<'a> Paging<'a> { + /// `validation` for a `token` without `paginate`, and for `paginate` without a non-zero + /// `limit`. + fn check(limit: Option, paginate: bool, token: Option<&'a str>) -> Result> { + if token.is_some() && !paginate { + return Err(NxfError::validation("a page token needs paginate")); + } + let page = match (paginate, limit) { + (false, _) => None, + (true, Some(n)) if n > 0 => Some(n), + (true, _) => return Err(NxfError::validation("paginate needs a limit of at least 1")), + }; + Ok(Paging { limit, page, token }) + } +} + +/// The log's position and identity when a list was computed — what a snapshot is checked against. +struct Watermark { + rowid: i64, + op: Option, +} + +impl Watermark { + fn read(store: &Store) -> Result { + let rowid = store.ops_watermark()?; + let op = store.op_at(rowid)?; + Ok(Watermark { rowid, op }) + } +} + +/// Cut the freshly computed, ordered `fresh` list into one page per `paging` — and, when it pages, +/// serve a token's page from its cached snapshot (if [`resume`] says it still answers) or cache +/// `fresh` under `key` for the next one. THE paging mechanism behind [`next_page`] and +/// [`mentioned_by_page`] (6j6v.15ed, generalised by 6j6v.q29p): one snapshot table, one expiry, +/// one pair of caps, one invalidation rule. `key` must name the read as well as its filters, so a +/// token of one read restarts under another; it is built only when the read pages. +fn page_through( + store: &Store, + fresh: Vec, + key: impl FnOnce() -> String, + paging: &Paging<'_>, + mark: Watermark, + clock: i64, +) -> Result { + let Some(n) = paging.page else { + return Ok(truncate_next(fresh, paging.limit)); }; - let key = q.key(cfg); - if let Some(token) = &q.token { + let key = key(); + if let Some(token) = paging.token { if let Some((snap, pos)) = resume(store, token, &key, &fresh, clock)? { // `pos < len` (resume checked it); a limit up to `usize::MAX` must not overflow. let end = pos.saturating_add(n).min(snap.ids.len()); @@ -1309,15 +1360,15 @@ fn next_page_at(cfg: &PluginConfig, store: &Store, q: &NextQuery, clock: i64) -> }); } } - let restarted = q.token.is_some(); + let restarted = paging.token.is_some(); let total = fresh.len(); let next_token = if total > n { let snap = nexus_flow_core::next_cache::CachedResult { id: ulid::Ulid::new().to_string(), created: clock, query: key, - watermark, - watermark_op, + watermark: mark.rowid, + watermark_op: mark.op, ids: fresh.iter().map(|i| i.id.clone()).collect(), }; store @@ -3051,6 +3102,140 @@ pub fn mentions_to_value(mentions: &[String]) -> Value { json!(mentions) } +// ---- mentioned by: the reverse of `mentions` (6j6v.q29p) -------------------- + +/// THE reverse mention lookup: for every id in `targets`, the live items with a present `mentions` +/// edge to it, id-sorted and distinct. A target nothing live mentions — or that is no item at all — +/// is absent from the map; this is a bulk read over ids the caller already holds, so a miss is not +/// `not_found`. +/// +/// "Live" is the one rule here: a deleted mentioner is dropped, every other one is kept — closed +/// and archived included, because a reference is not work. A caller narrows the rows itself: +/// [`mentioned_by_page`] by a [`NextFilter`], and the derived ranking key +/// `count = "mentioned_by"` (docs/specs/derived-ranking-keys.md §3.2, 6j6v.x0wf) by counting the +/// rows that pass its `type`/`status`/`label` parts per candidate. Spec §11 requires the two to +/// share one implementation; this is it. One edge query per 500 targets +/// ([`Store::mentioned_by_bulk`]) and one item read for the distinct mentioners, so a whole +/// candidate set costs O(E + N), as spec §5 budgets. +pub fn mentioners_of(store: &Store, targets: &[&str]) -> Result>> { + let edges = store.mentioned_by_bulk(targets)?; + let sources: BTreeSet<&str> = edges.values().flatten().map(String::as_str).collect(); + let sources: Vec<&str> = sources.into_iter().collect(); + let live: HashMap = store + .get_items(&sources)? + .into_iter() + .filter(|i| i.deleted.as_deref() != Some("1")) + .map(|i| (i.id.clone(), i)) + .collect(); + let mut out = BTreeMap::new(); + for (target, froms) in edges { + // `froms` is id-sorted and distinct (the store's ORDER BY over a DISTINCT view). + let rows: Vec = froms.iter().filter_map(|f| live.get(f).cloned()).collect(); + if !rows.is_empty() { + out.insert(target, rows); + } + } + Ok(out) +} + +/// One `mentioned_by` request (6j6v.q29p): which mentioners to keep, and how to page them — the +/// paging exactly as [`NextQuery`]'s (the same three shapes, the same snapshot cache, expiry, caps +/// and invalidation), because it is the same mechanism. +/// +/// - No `limit`: every mentioner. +/// - `limit` alone: the head, with the total disclosed and no token. +/// - `limit` + `paginate`: the head plus a `next_token` while rows remain; a stale or invalid +/// token answers the FIRST page of a fresh list with `restarted: true`. +/// +/// The filter is a [`NextFilter`] and means what it means for `next`: the types are alternatives, +/// the container keeps an item whose parent it is OR that contributes to it, the label keeps an +/// item carrying it. There is no sort: mentioners come in id order. +/// +/// `#[non_exhaustive]`: build it with [`MentionedByQuery::new`] and the builder methods. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct MentionedByQuery { + /// The filters; empty keeps every mentioner. + pub filter: NextFilter, + /// The page size; `None` is no cut. + pub limit: Option, + /// Hand out a next-token (requires a non-zero `limit`). + pub paginate: bool, + /// The token of a previous page (requires `paginate`). + pub token: Option, +} + +impl MentionedByQuery { + /// Every mentioner, unpaged. + pub fn new() -> MentionedByQuery { + MentionedByQuery::default() + } + + /// Narrow by `filter`. + pub fn filter(mut self, filter: NextFilter) -> MentionedByQuery { + self.filter = filter; + self + } + + /// Cut to `limit` rows (a page of that size under [`paginate`](Self::paginate)). + pub fn limit(mut self, limit: usize) -> MentionedByQuery { + self.limit = Some(limit); + self + } + + /// Ask for a next-token. + pub fn paginate(mut self) -> MentionedByQuery { + self.paginate = true; + self + } + + /// Continue from a previous page's token (implies [`paginate`](Self::paginate)). + pub fn token(mut self, token: impl Into) -> MentionedByQuery { + self.paginate = true; + self.token = Some(token.into()); + self + } + + /// The cache key a snapshot is bound to: the read, the target and the filter — never `limit`, + /// so a reader may change the page size between pages. Naming the read keeps a + /// `mentioned_by` token from answering a `next` query, and the other way round. + fn key(&self, target: &str) -> String { + json!({ + "read": "mentioned_by", + "target": target, + "filter": self.filter.key(), + }) + .to_string() + } +} + +/// One `mentioned_by` page (6j6v.q29p): the live items that mention `target` ([`mentioners_of`]), +/// narrowed by `q.filter`, in id order, cut and — under `paginate` — cached machine-locally like a +/// `next` page, so the following pages come from the same list. A token's snapshot restarts when +/// an op after it touches one of its items (a removed mention names the mentioner) or an item in it +/// left the fresh list. `not_found` if `target` is missing or deleted (as [`mentions`]); +/// `validation` for `paginate` without a non-zero `limit` or a token without `paginate`. +pub fn mentioned_by_page(store: &Store, target: &str, q: &MentionedByQuery) -> Result { + mentioned_by_page_at(store, target, q, unix_now()) +} + +/// [`mentioned_by_page`] on the clock `clock` (unix seconds). +fn mentioned_by_page_at( + store: &Store, + target: &str, + q: &MentionedByQuery, + clock: i64, +) -> Result { + let paging = Paging::check(q.limit, q.paginate, q.token.as_deref())?; + require_item(store, target)?; + let mark = Watermark::read(store)?; + let rows = mentioners_of(store, &[target])? + .remove(target) + .unwrap_or_default(); + let fresh = filter_next_store(store, rows, &q.filter)?; + page_through(store, fresh, || q.key(target), &paging, mark, clock) +} + // ---- thread links (nxf 6j6v.8dbe) ------------------------------------------ /// The chat threads presently linked to `id`, sorted by thread id. `not_found` if the item is @@ -4553,6 +4738,34 @@ mod tests { ); } + #[test] + fn a_mentioned_by_token_expires_on_the_same_clock() { + // 6j6v.q29p: `mentioned_by` pages through the same mechanism, expiry included. + let (mut s, _) = paged_board(); + for n in 0..4 { + s.add_edge( + &format!("c1.000{n}"), + "c1.0004", + nexus_flow_core::model::EdgeKind::Mentions, + "x", + ); + } + let q = MentionedByQuery::new().limit(2).paginate(); + let first = mentioned_by_page_at(&s, "c1.0004", &q, T0).unwrap(); + assert_eq!(first.total, 4); + let t = first.next_token.expect("rows remain"); + let at = |clock: i64| { + mentioned_by_page_at(&s, "c1.0004", &q.clone().token(&t), clock) + .unwrap() + .restarted + }; + assert!(!at(T0 + NEXT_CACHE_TTL_SECS), "at the TTL it still answers"); + assert!( + at(T0 + NEXT_CACHE_TTL_SECS + 1), + "past it the token restarts" + ); + } + #[test] fn a_new_snapshot_evicts_only_snapshots_older_than_the_ttl() { // The sign of the expiry bound: a younger snapshot survives the next insert; one older than diff --git a/crates/facade/tests/mentioned_by.rs b/crates/facade/tests/mentioned_by.rs new file mode 100644 index 0000000..9862bc6 --- /dev/null +++ b/crates/facade/tests/mentioned_by.rs @@ -0,0 +1,352 @@ +//! Mention edges read over the engine seam (6j6v.q29p): `Engine::mentions` (an item's outgoing +//! targets) and `Engine::mentioned_by` (the items that mention a target), the latter narrowed by +//! type and container and paged through the same machine-local snapshot `next` pages through +//! (6j6v.15ed). Tested at the seam the products take. + +use nexus_flow_core::model::EdgeKind; +use nexus_flow_core::store::Store; +use nexus_flow_facade::engine::Engine; +use nexus_flow_facade::error::ErrorKind; +use nexus_flow_facade::read::{self, MentionedByQuery, NextFilter, NextQuery}; +use nexus_flow_facade::workspace::{self, WorkspaceExt}; +use std::path::Path; +use tempfile::TempDir; + +const NOW: &str = "2026-06-17T00:00:00Z"; +const ACTOR: &str = "alice"; +const TARGET: &str = "ab12.t001"; + +fn workspace() -> TempDir { + let tmp = TempDir::new().unwrap(); + workspace::init_flow(tmp.path(), "issue-tracker").unwrap(); + tmp +} + +/// Another writer on the workspace — what a second process, or a sync pull, is to the engine. +fn writer(dir: &Path) -> Store { + workspace::discover(dir).unwrap().open_store().unwrap() +} + +fn item(s: &mut Store, id: &str, ty: &str) { + s.create_item(id, ty, id, "t"); + s.set_field(id, "priority", Some("2".into()), "t"); +} + +fn ids(items: &[nexus_flow_core::model::ItemRow]) -> Vec { + items.iter().map(|i| i.id.clone()).collect() +} + +fn by(engine: &Engine, q: MentionedByQuery) -> read::NextPage { + engine.mentioned_by(TARGET, &q).unwrap() +} + +/// A target, two containers, and mentioners of every kind: +/// +/// - `0001` task, parent e001 — mentions the target +/// - `0002` bug, contributes to e002 — mentions the target +/// - `0003` task, parent e002 AND contributes to e001 — mentions the target +/// - `0004` bug, in nothing, closed — mentions the target +/// - `0005` task, deleted — mentions the target +/// - `0006` task — mentions only e001, not the target +/// - `0007` task — its mention of the target was removed +fn board(dir: &Path) { + let mut s = writer(dir); + item(&mut s, TARGET, "feature"); + item(&mut s, "ab12.e001", "epic"); + item(&mut s, "ab12.e002", "epic"); + for (id, ty) in [ + ("ab12.0001", "task"), + ("ab12.0002", "bug"), + ("ab12.0003", "task"), + ("ab12.0004", "bug"), + ("ab12.0005", "task"), + ("ab12.0006", "task"), + ("ab12.0007", "task"), + ] { + item(&mut s, id, ty); + } + s.set_parent("ab12.0001", "ab12.e001", "t").unwrap(); + s.set_parent("ab12.0003", "ab12.e002", "t").unwrap(); + s.add_edge("ab12.0002", "ab12.e002", EdgeKind::ContributesTo, "t"); + s.add_edge("ab12.0003", "ab12.e001", EdgeKind::ContributesTo, "t"); + for from in [ + "ab12.0003", + "ab12.0001", + "ab12.0002", + "ab12.0004", + "ab12.0005", + "ab12.0007", + ] { + s.add_edge(from, TARGET, EdgeKind::Mentions, "t"); + } + s.add_edge("ab12.0006", "ab12.e001", EdgeKind::Mentions, "t"); + s.remove_edge("ab12.0007", TARGET, EdgeKind::Mentions, "t"); + s.set_field("ab12.0004", "status", Some("closed".into()), "t"); + s.delete_item("ab12.0005", "t"); +} + +const LIVE: [&str; 4] = ["ab12.0001", "ab12.0002", "ab12.0003", "ab12.0004"]; + +// ---- Engine::mentions ---------------------------------------------------------- + +#[test] +fn mentions_reads_the_outgoing_targets_over_the_seam() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + assert_eq!(engine.mentions("ab12.0006").unwrap(), ["ab12.e001"]); + assert_eq!(engine.mentions("ab12.0001").unwrap(), [TARGET]); + assert!(engine.mentions("ab12.0007").unwrap().is_empty(), "removed"); + // A write through the engine reads back through it. + engine + .mention_add(NOW, ACTOR, "ab12.0006", "ab12.e002") + .unwrap(); + assert_eq!( + engine.mentions("ab12.0006").unwrap(), + ["ab12.e001", "ab12.e002"] + ); + let err = engine.mentions("ab12.zzzz").unwrap_err(); + assert_eq!(err.kind, ErrorKind::NotFound); +} + +// ---- Engine::mentioned_by: the set and its filters ------------------------------- + +#[test] +fn mentioned_by_lists_the_live_mentioners_in_id_order() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let p = by(&engine, MentionedByQuery::new()); + // Closed counts (a reference is not work); deleted, removed and other targets' do not. + assert_eq!(ids(&p.items), LIVE); + assert_eq!((p.total, p.start, p.restarted), (4, 0, false)); + assert_eq!(p.next_token, None); + // Nothing mentions e002. + let none = engine + .mentioned_by("ab12.e002", &MentionedByQuery::new()) + .unwrap(); + assert!(none.items.is_empty()); + assert_eq!(none.total, 0); +} + +#[test] +fn mentioned_by_a_missing_or_deleted_target_is_not_found() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + for id in ["ab12.zzzz", "ab12.0005"] { + let err = engine + .mentioned_by(id, &MentionedByQuery::new()) + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::NotFound, "{id}"); + } +} + +#[test] +fn mentioned_by_filters_by_type_and_by_container() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let q = |f: NextFilter| ids(&by(&engine, MentionedByQuery::new().filter(f)).items); + + assert_eq!( + q(NextFilter::new().with_type("bug")), + ["ab12.0002", "ab12.0004"] + ); + assert_eq!( + q(NextFilter::new().with_type("bug").with_type("task")), + LIVE, + "the types are alternatives" + ); + // In e001: its child 0001 and its contributor 0003. In e002: contributor 0002, child 0003. + assert_eq!( + q(NextFilter::new().with_container("ab12.e001")), + ["ab12.0001", "ab12.0003"] + ); + assert_eq!( + q(NextFilter::new().with_container("ab12.e002")), + ["ab12.0002", "ab12.0003"] + ); + assert_eq!( + q(NextFilter::new() + .with_container("ab12.e002") + .with_type("task")), + ["ab12.0003"] + ); + assert!(q(NextFilter::new().with_type("epic")).is_empty()); +} + +// ---- Engine::mentioned_by: paging, as `next` pages --------------------------------- + +#[test] +fn a_limit_alone_cuts_and_discloses_but_hands_out_no_token() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let p = by(&engine, MentionedByQuery::new().limit(3)); + assert_eq!( + (ids(&p.items), p.total), + (LIVE[0..3].iter().map(|s| s.to_string()).collect(), 4) + ); + assert!(p.truncated()); + assert_eq!(p.next_token, None); +} + +#[test] +fn paginate_walks_the_whole_list_in_order() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let p1 = by(&engine, MentionedByQuery::new().limit(3).paginate()); + assert_eq!(ids(&p1.items), LIVE[0..3]); + let t = p1.next_token.expect("a row remains"); + let p2 = by(&engine, MentionedByQuery::new().limit(3).token(&t)); + assert_eq!( + (ids(&p2.items), p2.start, p2.total), + (vec![LIVE[3].to_string()], 3, 4) + ); + assert!(!p2.restarted); + assert_eq!(p2.next_token, None, "the last page"); +} + +#[test] +fn removing_a_mention_of_a_cached_item_restarts_at_the_first_page() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let p1 = by(&engine, MentionedByQuery::new().limit(2).paginate()); + // The edge op names the mentioner on the page not yet served: it bears on the loaded list. + engine + .mention_remove(NOW, ACTOR, "ab12.0003", TARGET) + .unwrap(); + let p = by( + &engine, + MentionedByQuery::new() + .limit(2) + .token(p1.next_token.unwrap()), + ); + assert!(p.restarted, "the snapshot is stale"); + assert_eq!( + (ids(&p.items), p.start, p.total), + (vec!["ab12.0001".to_string(), "ab12.0002".to_string()], 0, 3) + ); +} + +#[test] +fn an_outside_op_does_not_invalidate_and_a_new_mentioner_waits_for_a_fresh_query() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let p1 = by(&engine, MentionedByQuery::new().limit(2).paginate()); + // 0006 is not in the snapshot; its new mention of the target is not served by this pager. + engine.mention_add(NOW, ACTOR, "ab12.0006", TARGET).unwrap(); + let p2 = by( + &engine, + MentionedByQuery::new() + .limit(2) + .token(p1.next_token.unwrap()), + ); + assert!(!p2.restarted); + assert_eq!(ids(&p2.items), ["ab12.0003", "ab12.0004"]); + assert_eq!(p2.next_token, None); + assert_eq!( + by(&engine, MentionedByQuery::new()).total, + 5, + "a fresh query sees it" + ); +} + +#[test] +fn a_token_of_another_target_filter_or_read_restarts() { + let tmp = workspace(); + board(tmp.path()); + let mut s = writer(tmp.path()); + item(&mut s, "ab12.t002", "feature"); + for from in ["ab12.0001", "ab12.0002", "ab12.0003"] { + s.add_edge(from, "ab12.t002", EdgeKind::Mentions, "t"); + } + let engine = Engine::open(None, tmp.path()).unwrap(); + let t = by(&engine, MentionedByQuery::new().limit(2).paginate()) + .next_token + .unwrap(); + + let other_target = engine + .mentioned_by("ab12.t002", &MentionedByQuery::new().limit(2).token(&t)) + .unwrap(); + assert!(other_target.restarted, "another target"); + let other_filter = by( + &engine, + MentionedByQuery::new() + .filter(NextFilter::new().with_type("task")) + .limit(2) + .token(&t), + ); + assert!(other_filter.restarted, "another filter"); + let as_next = engine + .next_query(&NextQuery::new(NOW).limit(2).token(&t)) + .unwrap(); + assert!( + as_next.restarted, + "a mentioned-by token is not a next token" + ); + let garbage = by(&engine, MentionedByQuery::new().limit(2).token("nope")); + assert!(garbage.restarted); + assert_eq!(ids(&garbage.items), LIVE[0..2]); +} + +#[test] +fn paginate_needs_a_limit_and_a_token_needs_paginate() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + for q in [ + MentionedByQuery::new().paginate(), + MentionedByQuery::new().limit(0).paginate(), + ] { + let err = engine.mentioned_by(TARGET, &q).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Validation); + } + let mut q = MentionedByQuery::new().limit(2); + q.token = Some("x.2".into()); + let err = engine.mentioned_by(TARGET, &q).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Validation); +} + +#[test] +fn mentioned_by_value_is_the_paging_envelope_of_list_records() { + let tmp = workspace(); + board(tmp.path()); + let engine = Engine::open(None, tmp.path()).unwrap(); + let v = engine + .mentioned_by_value(TARGET, &MentionedByQuery::new().limit(2).paginate()) + .unwrap(); + assert_eq!(v["total"], 4); + assert_eq!(v["restarted"], false); + assert!(v["next_token"].is_string()); + let items = v["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["id"], "ab12.0001"); + assert_eq!(items[0]["type"], "task"); + // Without paginate: no token keys, as for `next`. + let plain = engine + .mentioned_by_value(TARGET, &MentionedByQuery::new()) + .unwrap(); + assert_eq!(plain["total"], 4); + assert!(plain.get("next_token").is_none()); +} + +// ---- the shared reverse lookup ------------------------------------------------------ + +#[test] +fn mentioners_of_answers_many_targets_in_one_read() { + // THE reverse lookup `mentioned_by` and the derived ranking key `count = "mentioned_by"` + // (6j6v.e354 §11) share. + let tmp = workspace(); + board(tmp.path()); + let s = writer(tmp.path()); + let m = read::mentioners_of(&s, &[TARGET, "ab12.e001", "ab12.e002", "ab12.zzzz"]).unwrap(); + assert_eq!(ids(&m[TARGET]), LIVE); + assert_eq!(ids(&m["ab12.e001"]), ["ab12.0006"]); + assert!(!m.contains_key("ab12.e002"), "nothing mentions it"); + assert!(!m.contains_key("ab12.zzzz"), "not an item, not an error"); +} diff --git a/docs/generated/cli.md b/docs/generated/cli.md index 93fffbf..d86e60d 100644 --- a/docs/generated/cli.md +++ b/docs/generated/cli.md @@ -27,6 +27,7 @@ This document contains the help content for the `nxf` command-line program. * [`nxf mention add`↴](#nxf-mention-add) * [`nxf mention remove`↴](#nxf-mention-remove) * [`nxf mention list`↴](#nxf-mention-list) +* [`nxf mention by`↴](#nxf-mention-by) * [`nxf contributes`↴](#nxf-contributes) * [`nxf contributes add`↴](#nxf-contributes-add) * [`nxf contributes remove`↴](#nxf-contributes-remove) @@ -73,7 +74,7 @@ nexus-flow agent CLI * `recap` — Recap the most recently closed work — "what got finished lately" — newest close first, archived closes INCLUDED. A recency recall view, distinct from `closed` (the lane, which drops archived). `--limit` caps the count (default 10); `--since` keeps only later closes * `next` — List actionable work — ready plus already-claimed, unblocked, not deferred — ranked by the active plugin's `next` policy and tiered to finish before starting: work you can close now, then each started epic with its open children, then the backlog. `--sort id` gives the flat, untiered order; `--type`, `--in` and `--label` narrow it without re-ranking; `--limit` shows only the head of it, and says so; `--paginate` adds a token for the next page * `dep` — Add or remove a dependency edge. Direction: `dep add A B` makes A depend on B, so B blocks A — A cannot be ready until B closes -* `mention` — Add, remove, or list reference edges (free-text short-id citations) +* `mention` — Add, remove, or list reference edges (free-text short-id citations), in either direction * `contributes` — Add, remove, or list contributes-to edges: `from` contributes to `to` (n:m). Like a mention it never blocks — it records a structural "feeds into" relation, not a dependency * `thread` — Link a chat thread to a board item, or list what a thread is about. This is how the conversation that did the work and the ticket it was about stop being two artefacts: a later reader of the item sees that there are conversations, without having to go looking * `label` — Add, remove, or list user labels (free-text tags) on an item. A label is user vocabulary, distinct from the plugin's display type; filter `list`/`next` with `--label` @@ -356,7 +357,7 @@ Remove the dependency where `from` depends on `to` ## `nxf mention` -Add, remove, or list reference edges (free-text short-id citations) +Add, remove, or list reference edges (free-text short-id citations), in either direction **Usage:** `nxf mention ` @@ -365,6 +366,7 @@ Add, remove, or list reference edges (free-text short-id citations) * `add` — Record that `from` cites the short-id `to` in its free text (no blocking) * `remove` — Remove the `from -> to` reference * `list` — List the short-ids `id` mentions +* `by` — List the items that mention `id` — the reverse of `list` — in id order, closed and archived mentioners included. `--type`, `--in` and `--label` narrow it as they narrow `next`; `--limit` shows only the head and says so; `--paginate` adds a token for the next page. Under `--json` the records are `list --json`'s, as a plain array — or, with `--limit`, as `{"items": [...], "total": }`, plus `"next_token"` (a string, or null on the last page) and `"restarted"` (bool) with `--paginate`, exactly as `next --limit --json` wraps them @@ -406,6 +408,27 @@ List the short-ids `id` mentions +## `nxf mention by` + +List the items that mention `id` — the reverse of `list` — in id order, closed and archived mentioners included. `--type`, `--in` and `--label` narrow it as they narrow `next`; `--limit` shows only the head and says so; `--paginate` adds a token for the next page. Under `--json` the records are `list --json`'s, as a plain array — or, with `--limit`, as `{"items": [...], "total": }`, plus `"next_token"` (a string, or null on the last page) and `"restarted"` (bool) with `--paginate`, exactly as `next --limit --json` wraps them + +**Usage:** `nxf mention by [OPTIONS] ` + +###### **Arguments:** + +* `` — The mentioned item + +###### **Options:** + +* `--type ` — Only mentioners of this type (the stored type, as `list --type` takes it). Repeatable: the types are alternatives +* `--in ` — Only mentioners in this container: its children (whose parent it is) and the items that contribute to it +* `--label