A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: merge network and local lexicons

Trezy 1b44ecb7 f50098b9

+637 -636
+13
migrations/20260221000000_merge_network_lexicons.sql
··· 1 + -- Merge network_lexicons metadata into the lexicons table. 2 + ALTER TABLE lexicons ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'; 3 + ALTER TABLE lexicons ADD COLUMN authority_did TEXT; 4 + ALTER TABLE lexicons ADD COLUMN last_fetched_at TIMESTAMPTZ; 5 + 6 + UPDATE lexicons 7 + SET source = 'network', 8 + authority_did = nl.authority_did, 9 + last_fetched_at = nl.last_fetched_at 10 + FROM network_lexicons nl 11 + WHERE lexicons.id = nl.nsid; 12 + 13 + DROP TABLE network_lexicons;
+38 -9
src/admin/lexicons.rs
··· 63 63 // Upsert into database 64 64 let row: (i32,) = sqlx::query_as( 65 65 r#" 66 - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action) 67 - VALUES ($1, $2, $3, $4, $5) 66 + INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, source) 67 + VALUES ($1, $2, $3, $4, $5, 'manual') 68 68 ON CONFLICT (id) DO UPDATE SET 69 69 lexicon_json = EXCLUDED.lexicon_json, 70 70 backfill = EXCLUDED.backfill, 71 71 target_collection = EXCLUDED.target_collection, 72 72 action = EXCLUDED.action, 73 + source = 'manual', 73 74 revision = lexicons.revision + 1, 74 75 updated_at = NOW() 75 76 RETURNING revision ··· 117 118 _admin: AdminAuth, 118 119 ) -> Result<Json<Vec<LexiconSummary>>, AppError> { 119 120 #[allow(clippy::type_complexity)] 120 - let rows: Vec<(String, i32, Value, bool, Option<String>, Option<String>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 121 + let rows: Vec<(String, i32, Value, bool, Option<String>, Option<String>, String, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 121 122 sqlx::query_as( 122 - "SELECT id, revision, lexicon_json, backfill, action, target_collection, created_at, updated_at FROM lexicons ORDER BY id", 123 + "SELECT id, revision, lexicon_json, backfill, action, target_collection, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons ORDER BY id", 123 124 ) 124 125 .fetch_all(&state.db) 125 126 .await ··· 128 129 let summaries: Vec<LexiconSummary> = rows 129 130 .into_iter() 130 131 .map( 131 - |(id, revision, json, backfill, action, target_collection, created_at, updated_at)| { 132 + |( 133 + id, 134 + revision, 135 + json, 136 + backfill, 137 + action, 138 + target_collection, 139 + source, 140 + authority_did, 141 + last_fetched_at, 142 + created_at, 143 + updated_at, 144 + )| { 132 145 let lexicon_type = 133 146 ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert) 134 147 .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) ··· 141 154 backfill, 142 155 action, 143 156 target_collection, 157 + source, 158 + authority_did, 159 + last_fetched_at, 144 160 created_at, 145 161 updated_at, 146 162 } ··· 158 174 Path(id): Path<String>, 159 175 ) -> Result<Json<Value>, AppError> { 160 176 #[allow(clippy::type_complexity)] 161 - let row: Option<(String, i32, Value, bool, Option<String>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 177 + let row: Option<(String, i32, Value, bool, Option<String>, String, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 162 178 sqlx::query_as( 163 - "SELECT id, revision, lexicon_json, backfill, action, created_at, updated_at FROM lexicons WHERE id = $1", 179 + "SELECT id, revision, lexicon_json, backfill, action, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons WHERE id = $1", 164 180 ) 165 181 .bind(&id) 166 182 .fetch_optional(&state.db) 167 183 .await 168 184 .map_err(|e| AppError::Internal(format!("failed to get lexicon: {e}")))?; 169 185 170 - let (id, revision, lexicon_json, backfill, action, created_at, updated_at) = 171 - row.ok_or_else(|| AppError::NotFound(format!("lexicon '{id}' not found")))?; 186 + let ( 187 + id, 188 + revision, 189 + lexicon_json, 190 + backfill, 191 + action, 192 + source, 193 + authority_did, 194 + last_fetched_at, 195 + created_at, 196 + updated_at, 197 + ) = row.ok_or_else(|| AppError::NotFound(format!("lexicon '{id}' not found")))?; 172 198 173 199 Ok(Json(serde_json::json!({ 174 200 "id": id, ··· 176 202 "lexicon_json": lexicon_json, 177 203 "backfill": backfill, 178 204 "action": action, 205 + "source": source, 206 + "authority_did": authority_did, 207 + "last_fetched_at": last_fetched_at, 179 208 "created_at": created_at, 180 209 "updated_at": updated_at, 181 210 })))
+12 -32
src/admin/network_lexicons.rs
··· 43 43 ) 44 44 .map_err(|e| AppError::BadRequest(format!("failed to parse lexicon: {e}")))?; 45 45 46 - // Insert into network_lexicons table. 47 - sqlx::query( 48 - r#" 49 - INSERT INTO network_lexicons (nsid, authority_did, target_collection, last_fetched_at) 50 - VALUES ($1, $2, $3, NOW()) 51 - ON CONFLICT (nsid) DO UPDATE SET 52 - authority_did = EXCLUDED.authority_did, 53 - target_collection = EXCLUDED.target_collection, 54 - last_fetched_at = NOW() 55 - "#, 56 - ) 57 - .bind(nsid) 58 - .bind(&authority_did) 59 - .bind(&body.target_collection) 60 - .execute(&state.db) 61 - .await 62 - .map_err(|e| AppError::Internal(format!("failed to insert network lexicon: {e}")))?; 63 - 64 - // Upsert into lexicons table. 46 + // Upsert into lexicons table with network source. 65 47 let row: (i32,) = sqlx::query_as( 66 48 r#" 67 - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection) 68 - VALUES ($1, $2, false, $3) 49 + INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at) 50 + VALUES ($1, $2, false, $3, 'network', $4, NOW()) 69 51 ON CONFLICT (id) DO UPDATE SET 70 52 lexicon_json = EXCLUDED.lexicon_json, 71 53 target_collection = EXCLUDED.target_collection, 54 + source = 'network', 55 + authority_did = EXCLUDED.authority_did, 56 + last_fetched_at = NOW(), 72 57 revision = lexicons.revision + 1, 73 58 updated_at = NOW() 74 59 RETURNING revision ··· 77 62 .bind(nsid) 78 63 .bind(&lexicon_json) 79 64 .bind(&body.target_collection) 65 + .bind(&authority_did) 80 66 .fetch_one(&state.db) 81 67 .await 82 - .map_err(|e| AppError::Internal(format!("failed to upsert lexicon: {e}")))?; 68 + .map_err(|e| AppError::Internal(format!("failed to upsert network lexicon: {e}")))?; 83 69 84 70 let revision = row.0; 85 71 ··· 114 100 _admin: AdminAuth, 115 101 ) -> Result<Json<Vec<NetworkLexiconSummary>>, AppError> { 116 102 #[allow(clippy::type_complexity)] 117 - let rows: Vec<(String, String, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>)> = 103 + let rows: Vec<(String, Option<String>, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>)> = 118 104 sqlx::query_as( 119 - "SELECT nsid, authority_did, target_collection, last_fetched_at, created_at FROM network_lexicons ORDER BY nsid", 105 + "SELECT id, authority_did, target_collection, last_fetched_at, created_at FROM lexicons WHERE source = 'network' ORDER BY id", 120 106 ) 121 107 .fetch_all(&state.db) 122 108 .await ··· 128 114 |(nsid, authority_did, target_collection, last_fetched_at, created_at)| { 129 115 NetworkLexiconSummary { 130 116 nsid, 131 - authority_did, 117 + authority_did: authority_did.unwrap_or_default(), 132 118 target_collection, 133 119 last_fetched_at, 134 120 created_at, ··· 146 132 _admin: AdminAuth, 147 133 Path(nsid): Path<String>, 148 134 ) -> Result<StatusCode, AppError> { 149 - let result = sqlx::query("DELETE FROM network_lexicons WHERE nsid = $1") 135 + let result = sqlx::query("DELETE FROM lexicons WHERE id = $1 AND source = 'network'") 150 136 .bind(&nsid) 151 137 .execute(&state.db) 152 138 .await ··· 157 143 "network lexicon '{nsid}' not found" 158 144 ))); 159 145 } 160 - 161 - // Also remove from lexicons table and registry. 162 - let _ = sqlx::query("DELETE FROM lexicons WHERE id = $1") 163 - .bind(&nsid) 164 - .execute(&state.db) 165 - .await; 166 146 167 147 state.lexicons.remove(&nsid).await; 168 148 notify_collections(&state).await;
+3
src/admin/types.rs
··· 13 13 pub(super) backfill: bool, 14 14 pub(super) action: Option<String>, 15 15 pub(super) target_collection: Option<String>, 16 + pub(super) source: String, 17 + pub(super) authority_did: Option<String>, 18 + pub(super) last_fetched_at: Option<chrono::DateTime<chrono::Utc>>, 16 19 pub(super) created_at: chrono::DateTime<chrono::Utc>, 17 20 pub(super) updated_at: chrono::DateTime<chrono::Utc>, 18 21 }
+12 -22
src/main.rs
··· 38 38 39 39 // Re-fetch all network lexicons from their respective PDSes. 40 40 let http = reqwest::Client::new(); 41 - let network_rows: Vec<(String, String, Option<String>)> = 42 - sqlx::query_as("SELECT nsid, authority_did, target_collection FROM network_lexicons") 43 - .fetch_all(&db) 44 - .await 45 - .unwrap_or_default(); 41 + let network_rows: Vec<(String, Option<String>, Option<String>)> = sqlx::query_as( 42 + "SELECT id, authority_did, target_collection FROM lexicons WHERE source = 'network'", 43 + ) 44 + .fetch_all(&db) 45 + .await 46 + .unwrap_or_default(); 46 47 47 48 for (nsid, _authority_did, target_collection) in &network_rows { 48 49 match resolve_nsid_authority(&http, &config.plc_url, nsid).await { ··· 56 57 ProcedureAction::Upsert, 57 58 ) { 58 59 Ok(parsed) => { 59 - // Upsert into lexicons table. 60 60 if let Err(e) = sqlx::query( 61 61 r#" 62 - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection) 63 - VALUES ($1, $2, false, $3) 64 - ON CONFLICT (id) DO UPDATE SET 65 - lexicon_json = EXCLUDED.lexicon_json, 66 - target_collection = EXCLUDED.target_collection, 67 - revision = lexicons.revision + 1, 62 + UPDATE lexicons 63 + SET lexicon_json = $2, 64 + last_fetched_at = NOW(), 65 + revision = revision + 1, 68 66 updated_at = NOW() 67 + WHERE id = $1 AND source = 'network' 69 68 "#, 70 69 ) 71 70 .bind(nsid) 72 71 .bind(&lexicon_json) 73 - .bind(target_collection) 74 72 .execute(&db) 75 73 .await 76 74 { 77 - warn!(nsid, "failed to upsert network lexicon into DB: {e}"); 75 + warn!(nsid, "failed to update network lexicon in DB: {e}"); 78 76 continue; 79 77 } 80 - 81 - // Update last_fetched_at. 82 - let _ = sqlx::query( 83 - "UPDATE network_lexicons SET last_fetched_at = NOW() WHERE nsid = $1", 84 - ) 85 - .bind(nsid) 86 - .execute(&db) 87 - .await; 88 78 89 79 lexicons.upsert(parsed).await; 90 80 info!(nsid, "refreshed network lexicon");
+6 -11
src/tap.rs
··· 384 384 385 385 // Check if this NSID is one we're tracking and the DID matches the authority. 386 386 let tracked: Option<(Option<String>,)> = sqlx::query_as( 387 - "SELECT target_collection FROM network_lexicons WHERE nsid = $1 AND authority_did = $2", 387 + "SELECT target_collection FROM lexicons WHERE id = $1 AND source = 'network' AND authority_did = $2", 388 388 ) 389 389 .bind(nsid) 390 390 .bind(did) ··· 419 419 420 420 let is_record = parsed.lexicon_type == crate::lexicon::LexiconType::Record; 421 421 422 - // Upsert into lexicons table. 422 + // Upsert into lexicons table with last_fetched_at. 423 423 if let Err(e) = sqlx::query( 424 424 r#" 425 - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection) 426 - VALUES ($1, $2, false, $3) 425 + INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at) 426 + VALUES ($1, $2, false, $3, 'network', $4, NOW()) 427 427 ON CONFLICT (id) DO UPDATE SET 428 428 lexicon_json = EXCLUDED.lexicon_json, 429 429 target_collection = EXCLUDED.target_collection, 430 + last_fetched_at = NOW(), 430 431 revision = lexicons.revision + 1, 431 432 updated_at = NOW() 432 433 "#, ··· 434 435 .bind(nsid) 435 436 .bind(rec) 436 437 .bind(&target_collection) 438 + .bind(did) 437 439 .execute(db) 438 440 .await 439 441 { 440 442 tracing::warn!(nsid, "failed to upsert lexicon from event: {e}"); 441 443 return; 442 444 } 443 - 444 - // Update last_fetched_at. 445 - let _ = 446 - sqlx::query("UPDATE network_lexicons SET last_fetched_at = NOW() WHERE nsid = $1") 447 - .bind(nsid) 448 - .execute(db) 449 - .await; 450 445 451 446 lexicons.upsert(parsed).await; 452 447 tracing::info!(nsid, "updated network lexicon from tap event");
+1 -1
tests/common/db.rs
··· 19 19 20 20 /// Truncate all application tables, preserving schema. 21 21 pub async fn truncate_all(pool: &PgPool) { 22 - sqlx::query("TRUNCATE records, lexicons, backfill_jobs, admins, network_lexicons RESTART IDENTITY CASCADE") 22 + sqlx::query("TRUNCATE records, lexicons, backfill_jobs, admins RESTART IDENTITY CASCADE") 23 23 .execute(pool) 24 24 .await 25 25 .expect("failed to truncate tables");
+12 -32
tests/e2e_network_lexicons.rs
··· 41 41 42 42 /// Set up mocks for NSID authority resolution: 43 43 /// - DNS TXT is not mockable in e2e, so we test at the API level by mocking 44 - /// the PLC directory and PDS responses and seeding the network_lexicons table directly. 44 + /// the PLC directory and PDS responses and seeding the lexicons table directly. 45 45 async fn seed_network_lexicon(app: &TestApp, nsid: &str, authority_did: &str) { 46 - sqlx::query( 47 - r#" 48 - INSERT INTO network_lexicons (nsid, authority_did, last_fetched_at) 49 - VALUES ($1, $2, NOW()) 50 - ON CONFLICT (nsid) DO NOTHING 51 - "#, 52 - ) 53 - .bind(nsid) 54 - .bind(authority_did) 55 - .execute(&app.state.db) 56 - .await 57 - .expect("failed to seed network lexicon"); 58 - 59 - // Also seed the lexicons table so it's consistent. 60 46 let lexicon_json = fixtures::game_record_lexicon(); 61 47 sqlx::query( 62 48 r#" 63 - INSERT INTO lexicons (id, lexicon_json, backfill) 64 - VALUES ($1, $2, false) 49 + INSERT INTO lexicons (id, lexicon_json, backfill, source, authority_did, last_fetched_at) 50 + VALUES ($1, $2, false, 'network', $3, NOW()) 65 51 ON CONFLICT (id) DO NOTHING 66 52 "#, 67 53 ) 68 54 .bind(nsid) 69 55 .bind(&lexicon_json) 56 + .bind(authority_did) 70 57 .execute(&app.state.db) 71 58 .await 72 - .expect("failed to seed lexicon"); 59 + .expect("failed to seed network lexicon"); 73 60 } 74 61 75 62 // --------------------------------------------------------------------------- ··· 136 123 137 124 assert_eq!(resp.status(), StatusCode::NO_CONTENT); 138 125 139 - // Verify network_lexicons table is empty. 140 - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM network_lexicons WHERE nsid = $1") 141 - .bind(nsid) 142 - .fetch_one(&app.state.db) 143 - .await 144 - .unwrap(); 145 - assert_eq!(count.0, 0); 146 - 147 - // Verify lexicons table is also cleaned up. 148 - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM lexicons WHERE id = $1") 149 - .bind(nsid) 150 - .fetch_one(&app.state.db) 151 - .await 152 - .unwrap(); 126 + // Verify lexicon is removed. 127 + let count: (i64,) = 128 + sqlx::query_as("SELECT COUNT(*) FROM lexicons WHERE id = $1 AND source = 'network'") 129 + .bind(nsid) 130 + .fetch_one(&app.state.db) 131 + .await 132 + .unwrap(); 153 133 assert_eq!(count.0, 0); 154 134 } 155 135
-20
web/package-lock.json
··· 112 112 "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", 113 113 "dev": true, 114 114 "license": "MIT", 115 - "peer": true, 116 115 "dependencies": { 117 116 "@babel/code-frame": "^7.29.0", 118 117 "@babel/generator": "^7.29.0", ··· 547 546 "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", 548 547 "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", 549 548 "license": "MIT", 550 - "peer": true, 551 549 "dependencies": { 552 550 "@dnd-kit/accessibility": "^3.1.1", 553 551 "@dnd-kit/utilities": "^3.2.2", ··· 755 753 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 756 754 "dev": true, 757 755 "license": "MIT", 758 - "peer": true, 759 756 "engines": { 760 757 "node": ">=12" 761 758 }, ··· 1951 1948 "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", 1952 1949 "dev": true, 1953 1950 "license": "MIT", 1954 - "peer": true, 1955 1951 "engines": { 1956 1952 "node": "^14.21.3 || >=16" 1957 1953 }, ··· 4116 4112 "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", 4117 4113 "dev": true, 4118 4114 "license": "MIT", 4119 - "peer": true, 4120 4115 "dependencies": { 4121 4116 "undici-types": "~6.21.0" 4122 4117 } ··· 4127 4122 "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 4128 4123 "devOptional": true, 4129 4124 "license": "MIT", 4130 - "peer": true, 4131 4125 "dependencies": { 4132 4126 "csstype": "^3.2.2" 4133 4127 } ··· 4138 4132 "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", 4139 4133 "devOptional": true, 4140 4134 "license": "MIT", 4141 - "peer": true, 4142 4135 "peerDependencies": { 4143 4136 "@types/react": "^19.2.0" 4144 4137 } ··· 4202 4195 "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", 4203 4196 "dev": true, 4204 4197 "license": "MIT", 4205 - "peer": true, 4206 4198 "dependencies": { 4207 4199 "@typescript-eslint/scope-manager": "8.55.0", 4208 4200 "@typescript-eslint/types": "8.55.0", ··· 4716 4708 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 4717 4709 "dev": true, 4718 4710 "license": "MIT", 4719 - "peer": true, 4720 4711 "bin": { 4721 4712 "acorn": "bin/acorn" 4722 4713 }, ··· 5103 5094 "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", 5104 5095 "devOptional": true, 5105 5096 "license": "MIT", 5106 - "peer": true, 5107 5097 "dependencies": { 5108 5098 "@babel/types": "^7.26.0" 5109 5099 } ··· 5193 5183 } 5194 5184 ], 5195 5185 "license": "MIT", 5196 - "peer": true, 5197 5186 "dependencies": { 5198 5187 "baseline-browser-mapping": "^2.9.0", 5199 5188 "caniuse-lite": "^1.0.30001759", ··· 6391 6380 "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", 6392 6381 "dev": true, 6393 6382 "license": "MIT", 6394 - "peer": true, 6395 6383 "dependencies": { 6396 6384 "@eslint-community/eslint-utils": "^4.8.0", 6397 6385 "@eslint-community/regexpp": "^4.12.1", ··· 6577 6565 "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", 6578 6566 "dev": true, 6579 6567 "license": "MIT", 6580 - "peer": true, 6581 6568 "dependencies": { 6582 6569 "@rtsao/scc": "^1.1.0", 6583 6570 "array-includes": "^3.1.9", ··· 6897 6884 "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", 6898 6885 "dev": true, 6899 6886 "license": "MIT", 6900 - "peer": true, 6901 6887 "dependencies": { 6902 6888 "accepts": "^2.0.0", 6903 6889 "body-parser": "^2.2.1", ··· 7636 7622 "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", 7637 7623 "dev": true, 7638 7624 "license": "MIT", 7639 - "peer": true, 7640 7625 "engines": { 7641 7626 "node": ">=16.9.0" 7642 7627 } ··· 10192 10177 "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", 10193 10178 "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", 10194 10179 "license": "MIT", 10195 - "peer": true, 10196 10180 "engines": { 10197 10181 "node": ">=0.10.0" 10198 10182 } ··· 10223 10207 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", 10224 10208 "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", 10225 10209 "license": "MIT", 10226 - "peer": true, 10227 10210 "dependencies": { 10228 10211 "scheduler": "^0.27.0" 10229 10212 }, ··· 11495 11478 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 11496 11479 "dev": true, 11497 11480 "license": "MIT", 11498 - "peer": true, 11499 11481 "engines": { 11500 11482 "node": ">=12" 11501 11483 }, ··· 11753 11735 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 11754 11736 "dev": true, 11755 11737 "license": "Apache-2.0", 11756 - "peer": true, 11757 11738 "bin": { 11758 11739 "tsc": "bin/tsc", 11759 11740 "tsserver": "bin/tsserver" ··· 12389 12370 "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", 12390 12371 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", 12391 12372 "license": "MIT", 12392 - "peer": true, 12393 12373 "funding": { 12394 12374 "url": "https://github.com/sponsors/colinhacks" 12395 12375 }
+528 -184
web/src/app/(dashboard)/lexicons/page.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useCallback, useEffect, useMemo, useState } from "react" 3 + import { 4 + type ColumnDef, 5 + type ColumnFiltersState, 6 + type PaginationState, 7 + type SortingState, 8 + type VisibilityState, 9 + getCoreRowModel, 10 + getFacetedRowModel, 11 + getFacetedUniqueValues, 12 + getFilteredRowModel, 13 + getPaginationRowModel, 14 + getSortedRowModel, 15 + useReactTable, 16 + } from "@tanstack/react-table"; 17 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 4 18 5 - import { useAuth } from "@/lib/auth-context" 19 + import { useAuth } from "@/lib/auth-context"; 6 20 import { 21 + addNetworkLexicon, 7 22 deleteLexicon, 23 + deleteNetworkLexicon, 8 24 getLexicon, 9 25 getLexicons, 10 26 uploadLexicon, 11 27 type LexiconDetail, 12 28 type LexiconSummary, 13 - } from "@/lib/api" 14 - import { SiteHeader } from "@/components/site-header" 15 - import { Badge } from "@/components/ui/badge" 16 - import { Button } from "@/components/ui/button" 29 + } from "@/lib/api"; 30 + import { DataTable } from "@/components/data-table/data-table"; 31 + import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; 32 + import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; 33 + import { SiteHeader } from "@/components/site-header"; 34 + import { Badge } from "@/components/ui/badge"; 35 + import { Button } from "@/components/ui/button"; 17 36 import { 18 37 Dialog, 19 38 DialogClose, ··· 23 42 DialogHeader, 24 43 DialogTitle, 25 44 DialogTrigger, 26 - } from "@/components/ui/dialog" 27 - import { Input } from "@/components/ui/input" 28 - import { Label } from "@/components/ui/label" 45 + } from "@/components/ui/dialog"; 46 + import { Input } from "@/components/ui/input"; 47 + import { Label } from "@/components/ui/label"; 29 48 import { 30 49 Select, 31 50 SelectContent, 32 51 SelectItem, 33 52 SelectTrigger, 34 53 SelectValue, 35 - } from "@/components/ui/select" 36 - import { Switch } from "@/components/ui/switch" 37 - import { 38 - Table, 39 - TableBody, 40 - TableCell, 41 - TableHead, 42 - TableHeader, 43 - TableRow, 44 - } from "@/components/ui/table" 45 - import { Textarea } from "@/components/ui/textarea" 54 + } from "@/components/ui/select"; 55 + import { Switch } from "@/components/ui/switch"; 56 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 57 + import { Textarea } from "@/components/ui/textarea"; 46 58 47 59 export default function LexiconsPage() { 48 - const { getToken } = useAuth() 49 - const [lexicons, setLexicons] = useState<LexiconSummary[]>([]) 50 - const [error, setError] = useState<string | null>(null) 51 - const [viewLexicon, setViewLexicon] = useState<LexiconDetail | null>(null) 60 + const { getToken } = useAuth(); 61 + const [lexicons, setLexicons] = useState<LexiconSummary[]>([]); 62 + const [error, setError] = useState<string | null>(null); 63 + const [viewLexicon, setViewLexicon] = useState<LexiconDetail | null>(null); 52 64 53 65 const load = useCallback(() => { 54 - getLexicons(getToken).then(setLexicons).catch((e) => setError(e.message)) 55 - }, [getToken]) 66 + getLexicons(getToken) 67 + .then(setLexicons) 68 + .catch((e) => setError(e.message)); 69 + }, [getToken]); 56 70 57 71 useEffect(() => { 58 - load() 59 - }, [load]) 72 + load(); 73 + }, [load]); 60 74 61 75 async function handleView(id: string) { 62 76 try { 63 - const detail = await getLexicon(getToken, id) 64 - setViewLexicon(detail) 77 + const detail = await getLexicon(getToken, id); 78 + setViewLexicon(detail); 65 79 } catch (e: unknown) { 66 - setError(e instanceof Error ? e.message : String(e)) 80 + setError(e instanceof Error ? e.message : String(e)); 67 81 } 68 82 } 69 83 70 - async function handleDelete(id: string) { 84 + async function handleDelete(lex: LexiconSummary) { 71 85 try { 72 - await deleteLexicon(getToken, id) 73 - load() 86 + if (lex.source === "network") { 87 + await deleteNetworkLexicon(getToken, lex.id); 88 + } else { 89 + await deleteLexicon(getToken, lex.id); 90 + } 91 + load(); 74 92 } catch (e: unknown) { 75 - setError(e instanceof Error ? e.message : String(e)) 93 + setError(e instanceof Error ? e.message : String(e)); 76 94 } 77 95 } 78 96 97 + const columns = useMemo<ColumnDef<LexiconSummary>[]>( 98 + () => [ 99 + { 100 + id: "id", 101 + accessorKey: "id", 102 + header: ({ column }) => ( 103 + <DataTableColumnHeader column={column} label="ID" /> 104 + ), 105 + cell: ({ row }) => ( 106 + <span className="font-mono text-sm">{row.original.id}</span> 107 + ), 108 + filterFn: "includesString", 109 + enableColumnFilter: true, 110 + enableSorting: true, 111 + enableHiding: false, 112 + meta: { 113 + label: "ID", 114 + placeholder: "Filter by ID...", 115 + variant: "text", 116 + }, 117 + }, 118 + { 119 + id: "lexicon_type", 120 + accessorKey: "lexicon_type", 121 + header: ({ column }) => ( 122 + <DataTableColumnHeader column={column} label="Type" /> 123 + ), 124 + cell: ({ row }) => ( 125 + <Badge variant="outline">{row.original.lexicon_type}</Badge> 126 + ), 127 + filterFn: (row, columnId, filterValue) => { 128 + if (!Array.isArray(filterValue) || filterValue.length === 0) 129 + return true; 130 + return filterValue.includes(row.getValue(columnId)); 131 + }, 132 + enableColumnFilter: true, 133 + enableSorting: true, 134 + meta: { 135 + label: "Type", 136 + variant: "multiSelect", 137 + options: [ 138 + { label: "Record", value: "record" }, 139 + { label: "Query", value: "query" }, 140 + { label: "Procedure", value: "procedure" }, 141 + ], 142 + }, 143 + }, 144 + { 145 + id: "source", 146 + accessorKey: "source", 147 + header: ({ column }) => ( 148 + <DataTableColumnHeader column={column} label="Source" /> 149 + ), 150 + cell: ({ row }) => ( 151 + <Badge 152 + variant={ 153 + row.original.source === "network" ? "secondary" : "outline" 154 + } 155 + > 156 + {row.original.source} 157 + </Badge> 158 + ), 159 + filterFn: (row, columnId, filterValue) => { 160 + if (!Array.isArray(filterValue) || filterValue.length === 0) 161 + return true; 162 + return filterValue.includes(row.getValue(columnId)); 163 + }, 164 + enableColumnFilter: true, 165 + enableSorting: true, 166 + meta: { 167 + label: "Source", 168 + variant: "select", 169 + options: [ 170 + { label: "Manual", value: "manual" }, 171 + { label: "Network", value: "network" }, 172 + ], 173 + }, 174 + }, 175 + { 176 + id: "action", 177 + accessorKey: "action", 178 + header: ({ column }) => ( 179 + <DataTableColumnHeader column={column} label="Action" /> 180 + ), 181 + cell: ({ row }) => row.original.action ?? "--", 182 + enableSorting: true, 183 + }, 184 + { 185 + id: "backfill", 186 + accessorKey: "backfill", 187 + header: ({ column }) => ( 188 + <DataTableColumnHeader column={column} label="Backfill" /> 189 + ), 190 + cell: ({ row }) => (row.original.backfill ? "Yes" : "No"), 191 + enableSorting: true, 192 + }, 193 + { 194 + id: "revision", 195 + accessorKey: "revision", 196 + header: ({ column }) => ( 197 + <DataTableColumnHeader column={column} label="Revision" /> 198 + ), 199 + cell: ({ row }) => ( 200 + <span className="tabular-nums">{row.original.revision}</span> 201 + ), 202 + enableSorting: true, 203 + }, 204 + { 205 + id: "actions", 206 + header: () => <span className="sr-only">Actions</span>, 207 + cell: ({ row }) => ( 208 + <div className="flex justify-end gap-2"> 209 + <Button 210 + variant="outline" 211 + size="sm" 212 + onClick={() => handleView(row.original.id)} 213 + > 214 + View 215 + </Button> 216 + <Button 217 + variant="destructive" 218 + size="sm" 219 + onClick={() => handleDelete(row.original)} 220 + > 221 + Delete 222 + </Button> 223 + </div> 224 + ), 225 + enableSorting: false, 226 + enableHiding: false, 227 + }, 228 + ], 229 + // eslint-disable-next-line react-hooks/exhaustive-deps 230 + [getToken], 231 + ); 232 + 233 + const [sorting, setSorting] = useState<SortingState>([ 234 + { id: "id", desc: false }, 235 + ]); 236 + const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); 237 + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); 238 + const [pagination, setPagination] = useState<PaginationState>({ 239 + pageIndex: 0, 240 + pageSize: 20, 241 + }); 242 + 243 + const table = useReactTable({ 244 + data: lexicons, 245 + columns, 246 + state: { 247 + sorting, 248 + columnFilters, 249 + columnVisibility, 250 + pagination, 251 + }, 252 + defaultColumn: { 253 + enableColumnFilter: false, 254 + }, 255 + onSortingChange: setSorting, 256 + onColumnFiltersChange: setColumnFilters, 257 + onColumnVisibilityChange: setColumnVisibility, 258 + onPaginationChange: setPagination, 259 + getCoreRowModel: getCoreRowModel(), 260 + getFilteredRowModel: getFilteredRowModel(), 261 + getSortedRowModel: getSortedRowModel(), 262 + getPaginationRowModel: getPaginationRowModel(), 263 + getFacetedRowModel: getFacetedRowModel(), 264 + getFacetedUniqueValues: getFacetedUniqueValues(), 265 + getRowId: (row) => row.id, 266 + }); 267 + 79 268 return ( 80 269 <> 81 270 <SiteHeader title="Lexicons" /> 82 271 <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 83 272 {error && <p className="text-destructive text-sm">{error}</p>} 84 273 85 - <div className="flex items-center justify-between"> 86 - <h2 className="text-lg font-semibold">Uploaded Lexicons</h2> 87 - <UploadDialog getToken={getToken} onSuccess={load} /> 88 - </div> 89 - 90 - <div className="rounded-lg border"> 91 - <Table> 92 - <TableHeader> 93 - <TableRow> 94 - <TableHead>ID</TableHead> 95 - <TableHead>Type</TableHead> 96 - <TableHead>Action</TableHead> 97 - <TableHead>Backfill</TableHead> 98 - <TableHead>Revision</TableHead> 99 - <TableHead className="text-right">Actions</TableHead> 100 - </TableRow> 101 - </TableHeader> 102 - <TableBody> 103 - {lexicons.length === 0 && ( 104 - <TableRow> 105 - <TableCell colSpan={6} className="text-muted-foreground text-center"> 106 - No lexicons uploaded yet. 107 - </TableCell> 108 - </TableRow> 109 - )} 110 - {lexicons.map((lex) => ( 111 - <TableRow key={lex.id}> 112 - <TableCell className="font-mono text-sm">{lex.id}</TableCell> 113 - <TableCell> 114 - <Badge variant="outline">{lex.lexicon_type}</Badge> 115 - </TableCell> 116 - <TableCell>{lex.action ?? "--"}</TableCell> 117 - <TableCell>{lex.backfill ? "Yes" : "No"}</TableCell> 118 - <TableCell className="tabular-nums">{lex.revision}</TableCell> 119 - <TableCell className="text-right"> 120 - <div className="flex justify-end gap-2"> 121 - <Button 122 - variant="outline" 123 - size="sm" 124 - onClick={() => handleView(lex.id)} 125 - > 126 - View 127 - </Button> 128 - <Button 129 - variant="destructive" 130 - size="sm" 131 - onClick={() => handleDelete(lex.id)} 132 - > 133 - Delete 134 - </Button> 135 - </div> 136 - </TableCell> 137 - </TableRow> 138 - ))} 139 - </TableBody> 140 - </Table> 141 - </div> 274 + <DataTable table={table}> 275 + <DataTableToolbar table={table}> 276 + <AddLexiconDialog getToken={getToken} onSuccess={load} /> 277 + </DataTableToolbar> 278 + </DataTable> 142 279 143 280 {viewLexicon && ( 144 281 <Dialog open onOpenChange={() => setViewLexicon(null)}> ··· 146 283 <DialogHeader> 147 284 <DialogTitle>{viewLexicon.id}</DialogTitle> 148 285 <DialogDescription> 149 - Revision {viewLexicon.revision} &middot; {viewLexicon.lexicon_type} 286 + Revision {viewLexicon.revision} &middot;{" "} 287 + {viewLexicon.lexicon_type} 150 288 </DialogDescription> 151 289 </DialogHeader> 152 290 <pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs"> ··· 157 295 )} 158 296 </div> 159 297 </> 160 - ) 298 + ); 161 299 } 162 300 163 - function UploadDialog({ 301 + // --------------------------------------------------------------------------- 302 + // Unified Add Lexicon dialog 303 + // --------------------------------------------------------------------------- 304 + 305 + function AddLexiconDialog({ 164 306 getToken, 165 307 onSuccess, 166 308 }: { 167 - getToken: () => Promise<string | null> 168 - onSuccess: () => void 309 + getToken: () => Promise<string | null>; 310 + onSuccess: () => void; 169 311 }) { 170 - const [json, setJson] = useState("") 171 - const [targetCollection, setTargetCollection] = useState("") 172 - const [action, setAction] = useState("") 173 - const [backfill, setBackfill] = useState(true) 174 - const [error, setError] = useState<string | null>(null) 175 - const [open, setOpen] = useState(false) 312 + const [open, setOpen] = useState(false); 313 + const [error, setError] = useState<string | null>(null); 176 314 177 - const mainType = useMemo(() => { 315 + // Local state 316 + const [json, setJson] = useState(""); 317 + const [localTargetCollection, setLocalTargetCollection] = useState(""); 318 + const [action, setAction] = useState(""); 319 + const [backfill, setBackfill] = useState(true); 320 + 321 + // Network state 322 + const [nsid, setNsid] = useState(""); 323 + const [networkTargetCollection, setNetworkTargetCollection] = useState(""); 324 + const [mainType, setMainType] = useState<string | undefined>(); 325 + const [resolving, setResolving] = useState(false); 326 + const abortRef = useRef<AbortController | null>(null); 327 + 328 + const localMainType = useMemo(() => { 178 329 try { 179 - const parsed = JSON.parse(json) 180 - return parsed?.defs?.main?.type as string | undefined 330 + const parsed = JSON.parse(json); 331 + return parsed?.defs?.main?.type as string | undefined; 181 332 } catch { 182 - return undefined 333 + return undefined; 183 334 } 184 - }, [json]) 335 + }, [json]); 336 + 337 + const showLocalTargetCollection = 338 + localMainType === "query" || localMainType === "procedure"; 339 + const showAction = localMainType === "procedure"; 340 + 341 + // Debounced NSID resolution 342 + useEffect(() => { 343 + abortRef.current?.abort(); 344 + setMainType(undefined); 345 + 346 + if (nsid.split(".").length < 3) return; 347 + 348 + const debounce = setTimeout(() => { 349 + const controller = new AbortController(); 350 + abortRef.current = controller; 351 + setResolving(true); 185 352 186 - const showTargetCollection = mainType === "query" || mainType === "procedure" 187 - const showAction = mainType === "procedure" 353 + resolveNsidType(nsid, controller.signal) 354 + .then((type) => { 355 + if (!controller.signal.aborted) setMainType(type); 356 + }) 357 + .finally(() => { 358 + if (!controller.signal.aborted) setResolving(false); 359 + }); 360 + }, 500); 361 + 362 + return () => clearTimeout(debounce); 363 + }, [nsid]); 364 + 365 + const showNetworkTargetCollection = 366 + mainType === "query" || mainType === "procedure"; 367 + 368 + function reset() { 369 + setError(null); 370 + setJson(""); 371 + setLocalTargetCollection(""); 372 + setAction(""); 373 + setBackfill(true); 374 + setNsid(""); 375 + setNetworkTargetCollection(""); 376 + setMainType(undefined); 377 + } 188 378 189 - async function handleUpload() { 190 - setError(null) 379 + async function handleUploadLocal() { 380 + setError(null); 191 381 try { 192 - const lexiconJson = JSON.parse(json) 382 + const lexiconJson = JSON.parse(json); 193 383 await uploadLexicon(getToken, { 194 384 lexicon_json: lexiconJson, 195 385 backfill, 196 - target_collection: showTargetCollection 197 - ? targetCollection || undefined 386 + target_collection: showLocalTargetCollection 387 + ? localTargetCollection || undefined 198 388 : undefined, 199 389 action: showAction ? action || undefined : undefined, 200 - }) 201 - setJson("") 202 - setTargetCollection("") 203 - setAction("") 204 - setBackfill(true) 205 - setOpen(false) 206 - onSuccess() 390 + }); 391 + reset(); 392 + setOpen(false); 393 + onSuccess(); 207 394 } catch (e: unknown) { 208 - setError(e instanceof Error ? e.message : String(e)) 395 + setError(e instanceof Error ? e.message : String(e)); 396 + } 397 + } 398 + 399 + async function handleAddNetwork() { 400 + setError(null); 401 + try { 402 + await addNetworkLexicon(getToken, { 403 + nsid, 404 + target_collection: showNetworkTargetCollection 405 + ? networkTargetCollection || undefined 406 + : undefined, 407 + }); 408 + reset(); 409 + setOpen(false); 410 + onSuccess(); 411 + } catch (e: unknown) { 412 + setError(e instanceof Error ? e.message : String(e)); 209 413 } 210 414 } 211 415 212 416 return ( 213 - <Dialog open={open} onOpenChange={setOpen}> 417 + <Dialog 418 + open={open} 419 + onOpenChange={(v) => { 420 + setOpen(v); 421 + if (!v) reset(); 422 + }} 423 + > 214 424 <DialogTrigger asChild> 215 - <Button>Upload Lexicon</Button> 425 + <Button>Add Lexicon</Button> 216 426 </DialogTrigger> 217 427 <DialogContent className="max-w-2xl"> 218 428 <DialogHeader> 219 - <DialogTitle>Upload Lexicon</DialogTitle> 429 + <DialogTitle>Add Lexicon</DialogTitle> 220 430 <DialogDescription> 221 - Paste the lexicon JSON document below. 431 + Upload a local lexicon JSON document or track one from the network. 222 432 </DialogDescription> 223 433 </DialogHeader> 224 - <div className="flex min-w-0 flex-col gap-4 overflow-hidden"> 225 - {error && <p className="text-destructive text-sm">{error}</p>} 226 - <div className="flex flex-col gap-2"> 227 - <Label htmlFor="lexicon-json">Lexicon JSON</Label> 228 - <Textarea 229 - id="lexicon-json" 230 - className="font-mono text-xs" 231 - rows={12} 232 - value={json} 233 - onChange={(e) => setJson(e.target.value)} 234 - placeholder='{"lexicon": 1, "id": "com.example.record", ...}' 235 - /> 236 - </div> 237 - {showTargetCollection && ( 238 - <div className="flex flex-col gap-2"> 239 - <Label htmlFor="target-collection"> 240 - Target Collection (optional) 241 - </Label> 242 - <Input 243 - id="target-collection" 244 - value={targetCollection} 245 - onChange={(e) => setTargetCollection(e.target.value)} 246 - placeholder="com.example.record" 247 - /> 434 + <Tabs defaultValue="local"> 435 + <TabsList className="w-full"> 436 + <TabsTrigger value="local" className="flex-1"> 437 + Local 438 + </TabsTrigger> 439 + <TabsTrigger value="network" className="flex-1"> 440 + Network 441 + </TabsTrigger> 442 + </TabsList> 443 + 444 + <TabsContent value="local"> 445 + <div className="flex min-w-0 flex-col gap-4 overflow-hidden pt-4"> 446 + {error && <p className="text-destructive text-sm">{error}</p>} 447 + <div className="flex flex-col gap-2"> 448 + <Label htmlFor="lexicon-json">Lexicon JSON</Label> 449 + <Textarea 450 + id="lexicon-json" 451 + className="font-mono text-xs" 452 + rows={12} 453 + value={json} 454 + onChange={(e) => setJson(e.target.value)} 455 + placeholder='{"lexicon": 1, "id": "com.example.record", ...}' 456 + /> 457 + </div> 458 + {showLocalTargetCollection && ( 459 + <div className="flex flex-col gap-2"> 460 + <Label htmlFor="target-collection"> 461 + Target Collection (optional) 462 + </Label> 463 + <Input 464 + id="target-collection" 465 + value={localTargetCollection} 466 + onChange={(e) => setLocalTargetCollection(e.target.value)} 467 + placeholder="com.example.record" 468 + /> 469 + </div> 470 + )} 471 + {showAction && ( 472 + <div className="flex flex-col gap-2"> 473 + <Label htmlFor="action">Action (optional)</Label> 474 + <Select value={action} onValueChange={setAction}> 475 + <SelectTrigger id="action" className="w-full"> 476 + <SelectValue placeholder="Upsert (default)" /> 477 + </SelectTrigger> 478 + <SelectContent> 479 + <SelectItem value="upsert">Upsert (default)</SelectItem> 480 + <SelectItem value="create">Create</SelectItem> 481 + <SelectItem value="update">Update</SelectItem> 482 + <SelectItem value="delete">Delete</SelectItem> 483 + </SelectContent> 484 + </Select> 485 + </div> 486 + )} 487 + <div className="flex items-center gap-2"> 488 + <Switch 489 + id="backfill" 490 + checked={backfill} 491 + onCheckedChange={setBackfill} 492 + /> 493 + <Label htmlFor="backfill">Enable backfill</Label> 494 + </div> 495 + <DialogFooter> 496 + <DialogClose asChild> 497 + <Button variant="outline">Cancel</Button> 498 + </DialogClose> 499 + <Button onClick={handleUploadLocal}>Upload</Button> 500 + </DialogFooter> 248 501 </div> 249 - )} 250 - {showAction && ( 251 - <div className="flex flex-col gap-2"> 252 - <Label htmlFor="action">Action (optional)</Label> 253 - <Select value={action} onValueChange={setAction}> 254 - <SelectTrigger id="action" className="w-full"> 255 - <SelectValue placeholder="Upsert (default)" /> 256 - </SelectTrigger> 257 - <SelectContent> 258 - <SelectItem value="upsert">Upsert (default)</SelectItem> 259 - <SelectItem value="create">Create</SelectItem> 260 - <SelectItem value="update">Update</SelectItem> 261 - <SelectItem value="delete">Delete</SelectItem> 262 - </SelectContent> 263 - </Select> 502 + </TabsContent> 503 + 504 + <TabsContent value="network"> 505 + <div className="flex flex-col gap-4 pt-4"> 506 + {error && <p className="text-destructive text-sm">{error}</p>} 507 + <div className="flex flex-col gap-2"> 508 + <Label htmlFor="nsid">NSID</Label> 509 + <Input 510 + id="nsid" 511 + value={nsid} 512 + onChange={(e) => setNsid(e.target.value)} 513 + placeholder="com.example.record" 514 + /> 515 + {resolving && ( 516 + <p className="text-muted-foreground text-xs"> 517 + Resolving lexicon... 518 + </p> 519 + )} 520 + </div> 521 + {showNetworkTargetCollection && ( 522 + <div className="flex flex-col gap-2"> 523 + <Label htmlFor="nl-target-collection"> 524 + Target Collection (optional) 525 + </Label> 526 + <Input 527 + id="nl-target-collection" 528 + value={networkTargetCollection} 529 + onChange={(e) => setNetworkTargetCollection(e.target.value)} 530 + placeholder="com.example.record" 531 + /> 532 + </div> 533 + )} 534 + <DialogFooter> 535 + <DialogClose asChild> 536 + <Button variant="outline">Cancel</Button> 537 + </DialogClose> 538 + <Button onClick={handleAddNetwork}>Add</Button> 539 + </DialogFooter> 264 540 </div> 265 - )} 266 - <div className="flex items-center gap-2"> 267 - <Switch 268 - id="backfill" 269 - checked={backfill} 270 - onCheckedChange={setBackfill} 271 - /> 272 - <Label htmlFor="backfill">Enable backfill</Label> 273 - </div> 274 - </div> 275 - <DialogFooter> 276 - <DialogClose asChild> 277 - <Button variant="outline">Cancel</Button> 278 - </DialogClose> 279 - <Button onClick={handleUpload}>Upload</Button> 280 - </DialogFooter> 541 + </TabsContent> 542 + </Tabs> 281 543 </DialogContent> 282 544 </Dialog> 283 - ) 545 + ); 546 + } 547 + 548 + // --------------------------------------------------------------------------- 549 + // Network NSID resolution helpers 550 + // --------------------------------------------------------------------------- 551 + 552 + function nsidToDomain(nsid: string): string | null { 553 + const parts = nsid.split("."); 554 + if (parts.length < 3) return null; 555 + const authority = parts.slice(0, -1).reverse(); 556 + return authority.join("."); 557 + } 558 + 559 + async function resolveNsidType( 560 + nsid: string, 561 + signal: AbortSignal, 562 + ): Promise<string | undefined> { 563 + const domain = nsidToDomain(nsid); 564 + if (!domain) return undefined; 565 + 566 + let did: string | undefined; 567 + try { 568 + const resp = await fetch(`https://${domain}/.well-known/atproto-did`, { 569 + signal, 570 + }); 571 + if (resp.ok) did = (await resp.text()).trim(); 572 + } catch (e) { 573 + if (signal.aborted) return undefined; 574 + } 575 + 576 + if (!did) { 577 + try { 578 + const resp = await fetch( 579 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 580 + { signal }, 581 + ); 582 + if (resp.ok) { 583 + const data = await resp.json(); 584 + did = data.did; 585 + } 586 + } catch (e) { 587 + if (signal.aborted) return undefined; 588 + } 589 + } 590 + 591 + if (!did) return undefined; 592 + 593 + let pdsEndpoint: string | undefined; 594 + try { 595 + const resp = await fetch( 596 + `https://plc.directory/${encodeURIComponent(did)}`, 597 + { signal }, 598 + ); 599 + if (resp.ok) { 600 + const doc = await resp.json(); 601 + const services = doc.service as 602 + | { id: string; serviceEndpoint: string }[] 603 + | undefined; 604 + pdsEndpoint = services?.find( 605 + (s) => s.id === "#atproto_pds", 606 + )?.serviceEndpoint; 607 + } 608 + } catch (e) { 609 + if (signal.aborted) return undefined; 610 + } 611 + 612 + if (!pdsEndpoint) return undefined; 613 + 614 + try { 615 + const resp = await fetch( 616 + `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`, 617 + { signal }, 618 + ); 619 + if (resp.ok) { 620 + const data = await resp.json(); 621 + return data.value?.defs?.main?.type as string | undefined; 622 + } 623 + } catch { 624 + // Best-effort resolution 625 + } 626 + 627 + return undefined; 284 628 }
-322
web/src/app/(dashboard)/network-lexicons/page.tsx
··· 1 - "use client" 2 - 3 - import { useCallback, useEffect, useRef, useState } from "react" 4 - 5 - import { useAuth } from "@/lib/auth-context" 6 - import { 7 - addNetworkLexicon, 8 - deleteNetworkLexicon, 9 - getNetworkLexicons, 10 - type NetworkLexiconSummary, 11 - } from "@/lib/api" 12 - import { SiteHeader } from "@/components/site-header" 13 - import { Button } from "@/components/ui/button" 14 - import { 15 - Dialog, 16 - DialogClose, 17 - DialogContent, 18 - DialogDescription, 19 - DialogFooter, 20 - DialogHeader, 21 - DialogTitle, 22 - DialogTrigger, 23 - } from "@/components/ui/dialog" 24 - import { Input } from "@/components/ui/input" 25 - import { Label } from "@/components/ui/label" 26 - import { 27 - Table, 28 - TableBody, 29 - TableCell, 30 - TableHead, 31 - TableHeader, 32 - TableRow, 33 - } from "@/components/ui/table" 34 - 35 - export default function NetworkLexiconsPage() { 36 - const { getToken } = useAuth() 37 - const [items, setItems] = useState<NetworkLexiconSummary[]>([]) 38 - const [error, setError] = useState<string | null>(null) 39 - 40 - const load = useCallback(() => { 41 - getNetworkLexicons(getToken).then(setItems).catch((e) => setError(e.message)) 42 - }, [getToken]) 43 - 44 - useEffect(() => { 45 - load() 46 - }, [load]) 47 - 48 - async function handleDelete(nsid: string) { 49 - try { 50 - await deleteNetworkLexicon(getToken, nsid) 51 - load() 52 - } catch (e: unknown) { 53 - setError(e instanceof Error ? e.message : String(e)) 54 - } 55 - } 56 - 57 - return ( 58 - <> 59 - <SiteHeader title="Network Lexicons" /> 60 - <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 61 - {error && <p className="text-destructive text-sm">{error}</p>} 62 - 63 - <div className="flex items-center justify-between"> 64 - <h2 className="text-lg font-semibold">Tracked Network Lexicons</h2> 65 - <AddDialog getToken={getToken} onSuccess={load} /> 66 - </div> 67 - 68 - <div className="rounded-lg border"> 69 - <Table> 70 - <TableHeader> 71 - <TableRow> 72 - <TableHead>NSID</TableHead> 73 - <TableHead>Authority DID</TableHead> 74 - <TableHead>Target Collection</TableHead> 75 - <TableHead>Last Fetched</TableHead> 76 - <TableHead className="text-right">Actions</TableHead> 77 - </TableRow> 78 - </TableHeader> 79 - <TableBody> 80 - {items.length === 0 && ( 81 - <TableRow> 82 - <TableCell 83 - colSpan={5} 84 - className="text-muted-foreground text-center" 85 - > 86 - No network lexicons tracked yet. 87 - </TableCell> 88 - </TableRow> 89 - )} 90 - {items.map((item) => ( 91 - <TableRow key={item.nsid}> 92 - <TableCell className="font-mono text-sm"> 93 - {item.nsid} 94 - </TableCell> 95 - <TableCell className="font-mono text-sm"> 96 - {item.authority_did} 97 - </TableCell> 98 - <TableCell className="font-mono text-sm"> 99 - {item.target_collection ?? "--"} 100 - </TableCell> 101 - <TableCell> 102 - {item.last_fetched_at 103 - ? new Date(item.last_fetched_at).toLocaleString() 104 - : "Never"} 105 - </TableCell> 106 - <TableCell className="text-right"> 107 - <Button 108 - variant="destructive" 109 - size="sm" 110 - onClick={() => handleDelete(item.nsid)} 111 - > 112 - Delete 113 - </Button> 114 - </TableCell> 115 - </TableRow> 116 - ))} 117 - </TableBody> 118 - </Table> 119 - </div> 120 - </div> 121 - </> 122 - ) 123 - } 124 - 125 - // Extract the authority domain from an NSID. 126 - // e.g. "games.gamesgamesgamesgames.createGame" → "gamesgamesgamesgames.games" 127 - function nsidToDomain(nsid: string): string | null { 128 - const parts = nsid.split(".") 129 - if (parts.length < 3) return null 130 - const authority = parts.slice(0, -1).reverse() 131 - return authority.join(".") 132 - } 133 - 134 - // Resolve an NSID to its lexicon's main def type by fetching from the network. 135 - async function resolveNsidType( 136 - nsid: string, 137 - signal: AbortSignal 138 - ): Promise<string | undefined> { 139 - const domain = nsidToDomain(nsid) 140 - if (!domain) return undefined 141 - 142 - // Resolve handle → DID 143 - let did: string | undefined 144 - try { 145 - const resp = await fetch( 146 - `https://${domain}/.well-known/atproto-did`, 147 - { signal } 148 - ) 149 - if (resp.ok) did = (await resp.text()).trim() 150 - } catch (e) { 151 - if (signal.aborted) return undefined 152 - } 153 - 154 - if (!did) { 155 - try { 156 - const resp = await fetch( 157 - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 158 - { signal } 159 - ) 160 - if (resp.ok) { 161 - const data = await resp.json() 162 - did = data.did 163 - } 164 - } catch (e) { 165 - if (signal.aborted) return undefined 166 - } 167 - } 168 - 169 - if (!did) return undefined 170 - 171 - // Resolve DID → PDS endpoint 172 - let pdsEndpoint: string | undefined 173 - try { 174 - const resp = await fetch( 175 - `https://plc.directory/${encodeURIComponent(did)}`, 176 - { signal } 177 - ) 178 - if (resp.ok) { 179 - const doc = await resp.json() 180 - const services = doc.service as 181 - | { id: string; serviceEndpoint: string }[] 182 - | undefined 183 - pdsEndpoint = services?.find( 184 - (s) => s.id === "#atproto_pds" 185 - )?.serviceEndpoint 186 - } 187 - } catch (e) { 188 - if (signal.aborted) return undefined 189 - } 190 - 191 - if (!pdsEndpoint) return undefined 192 - 193 - // Fetch lexicon record from PDS 194 - try { 195 - const resp = await fetch( 196 - `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`, 197 - { signal } 198 - ) 199 - if (resp.ok) { 200 - const data = await resp.json() 201 - return data.value?.defs?.main?.type as string | undefined 202 - } 203 - } catch { 204 - // Best-effort resolution 205 - } 206 - 207 - return undefined 208 - } 209 - 210 - function AddDialog({ 211 - getToken, 212 - onSuccess, 213 - }: { 214 - getToken: () => Promise<string | null> 215 - onSuccess: () => void 216 - }) { 217 - const [nsid, setNsid] = useState("") 218 - const [targetCollection, setTargetCollection] = useState("") 219 - const [mainType, setMainType] = useState<string | undefined>() 220 - const [resolving, setResolving] = useState(false) 221 - const [error, setError] = useState<string | null>(null) 222 - const [open, setOpen] = useState(false) 223 - const abortRef = useRef<AbortController | null>(null) 224 - 225 - // Debounced NSID resolution 226 - useEffect(() => { 227 - abortRef.current?.abort() 228 - setMainType(undefined) 229 - 230 - // Need at least 3 segments (e.g. "com.example.thing") 231 - if (nsid.split(".").length < 3) return 232 - 233 - const debounce = setTimeout(() => { 234 - const controller = new AbortController() 235 - abortRef.current = controller 236 - setResolving(true) 237 - 238 - resolveNsidType(nsid, controller.signal) 239 - .then((type) => { 240 - if (!controller.signal.aborted) setMainType(type) 241 - }) 242 - .finally(() => { 243 - if (!controller.signal.aborted) setResolving(false) 244 - }) 245 - }, 500) 246 - 247 - return () => clearTimeout(debounce) 248 - }, [nsid]) 249 - 250 - const showTargetCollection = mainType === "query" || mainType === "procedure" 251 - 252 - async function handleAdd() { 253 - setError(null) 254 - try { 255 - await addNetworkLexicon(getToken, { 256 - nsid, 257 - target_collection: showTargetCollection 258 - ? targetCollection || undefined 259 - : undefined, 260 - }) 261 - setNsid("") 262 - setTargetCollection("") 263 - setMainType(undefined) 264 - setOpen(false) 265 - onSuccess() 266 - } catch (e: unknown) { 267 - setError(e instanceof Error ? e.message : String(e)) 268 - } 269 - } 270 - 271 - return ( 272 - <Dialog open={open} onOpenChange={setOpen}> 273 - <DialogTrigger asChild> 274 - <Button>Add Network Lexicon</Button> 275 - </DialogTrigger> 276 - <DialogContent> 277 - <DialogHeader> 278 - <DialogTitle>Add Network Lexicon</DialogTitle> 279 - <DialogDescription> 280 - Track a lexicon from the ATProto network by its NSID. 281 - </DialogDescription> 282 - </DialogHeader> 283 - <div className="flex flex-col gap-4"> 284 - {error && <p className="text-destructive text-sm">{error}</p>} 285 - <div className="flex flex-col gap-2"> 286 - <Label htmlFor="nsid">NSID</Label> 287 - <Input 288 - id="nsid" 289 - value={nsid} 290 - onChange={(e) => setNsid(e.target.value)} 291 - placeholder="com.example.record" 292 - /> 293 - {resolving && ( 294 - <p className="text-muted-foreground text-xs"> 295 - Resolving lexicon... 296 - </p> 297 - )} 298 - </div> 299 - {showTargetCollection && ( 300 - <div className="flex flex-col gap-2"> 301 - <Label htmlFor="nl-target-collection"> 302 - Target Collection (optional) 303 - </Label> 304 - <Input 305 - id="nl-target-collection" 306 - value={targetCollection} 307 - onChange={(e) => setTargetCollection(e.target.value)} 308 - placeholder="com.example.record" 309 - /> 310 - </div> 311 - )} 312 - </div> 313 - <DialogFooter> 314 - <DialogClose asChild> 315 - <Button variant="outline">Cancel</Button> 316 - </DialogClose> 317 - <Button onClick={handleAdd}>Add</Button> 318 - </DialogFooter> 319 - </DialogContent> 320 - </Dialog> 321 - ) 322 - }
-2
web/src/components/app-sidebar.tsx
··· 3 3 import { 4 4 IconDashboard, 5 5 IconFileDescription, 6 - IconWorld, 7 6 IconDatabase, 8 7 IconTable, 9 8 IconUsers, ··· 28 27 const navItems = [ 29 28 { title: "Dashboard", url: "/", icon: IconDashboard }, 30 29 { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, 31 - { title: "Network Lexicons", url: "/network-lexicons", icon: IconWorld }, 32 30 { title: "Backfill", url: "/backfill", icon: IconDatabase }, 33 31 { title: "Records", url: "/records", icon: IconTable }, 34 32 { title: "Admins", url: "/admins", icon: IconUsers },
+1
web/src/components/data-table/data-table-column-header.tsx
··· 1 1 "use client"; 2 + "use no memo"; 2 3 3 4 import type { Column } from "@tanstack/react-table"; 4 5 import {
+1
web/src/components/data-table/data-table-faceted-filter.tsx
··· 1 1 "use client"; 2 + "use no memo"; 2 3 3 4 import type { Column } from "@tanstack/react-table"; 4 5 import { Check, PlusCircle, XCircle } from "lucide-react";
+2
web/src/components/data-table/data-table-pagination.tsx
··· 1 + "use no memo"; 2 + 1 3 import type { Table } from "@tanstack/react-table"; 2 4 import { 3 5 ChevronLeft,
+1
web/src/components/data-table/data-table-toolbar.tsx
··· 1 1 "use client"; 2 + "use no memo"; 2 3 3 4 import type { Column, Table } from "@tanstack/react-table"; 4 5 import { X } from "lucide-react";
+1
web/src/components/data-table/data-table-view-options.tsx
··· 1 1 "use client"; 2 + "use no memo"; 2 3 3 4 import type { Table } from "@tanstack/react-table"; 4 5 import { Check, Settings2 } from "lucide-react";
+2
web/src/components/data-table/data-table.tsx
··· 1 + "use no memo"; 2 + 1 3 import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"; 2 4 import type * as React from "react"; 3 5
+3
web/src/lib/api.ts
··· 90 90 backfill: boolean 91 91 action: string | null 92 92 target_collection: string | null 93 + source: string 94 + authority_did: string | null 95 + last_fetched_at: string | null 93 96 created_at: string 94 97 updated_at: string 95 98 }
+1 -1
web/src/lib/data-table.ts
··· 31 31 right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, 32 32 opacity: isPinned ? 0.97 : 1, 33 33 position: isPinned ? "sticky" : "relative", 34 - background: isPinned ? "var(--background)" : "var(--background)", 34 + background: isPinned ? "var(--background)" : undefined, 35 35 width: column.getSize(), 36 36 zIndex: isPinned ? 1 : undefined, 37 37 };