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: add custom processing scripts to procedure and query lexicons

Trezy 57ff6b88 69893ff8

+3930 -696
+129
Cargo.lock
··· 163 163 ] 164 164 165 165 [[package]] 166 + name = "bstr" 167 + version = "1.12.1" 168 + source = "registry+https://github.com/rust-lang/crates.io-index" 169 + checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" 170 + dependencies = [ 171 + "memchr", 172 + "serde", 173 + ] 174 + 175 + [[package]] 166 176 name = "bumpalo" 167 177 version = "3.19.1" 168 178 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 464 474 ] 465 475 466 476 [[package]] 477 + name = "env_home" 478 + version = "0.1.0" 479 + source = "registry+https://github.com/rust-lang/crates.io-index" 480 + checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" 481 + 482 + [[package]] 467 483 name = "equivalent" 468 484 version = "1.0.2" 469 485 source = "registry+https://github.com/rust-lang/crates.io-index" 470 486 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 471 487 472 488 [[package]] 489 + name = "erased-serde" 490 + version = "0.4.9" 491 + source = "registry+https://github.com/rust-lang/crates.io-index" 492 + checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" 493 + dependencies = [ 494 + "serde", 495 + "serde_core", 496 + "typeid", 497 + ] 498 + 499 + [[package]] 473 500 name = "errno" 474 501 version = "0.3.14" 475 502 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 762 789 "hickory-resolver", 763 790 "http-body-util", 764 791 "jsonwebtoken", 792 + "mlua", 765 793 "p256", 766 794 "reqwest", 767 795 "serde", ··· 1307 1335 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1308 1336 1309 1337 [[package]] 1338 + name = "lua-src" 1339 + version = "550.0.0" 1340 + source = "registry+https://github.com/rust-lang/crates.io-index" 1341 + checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" 1342 + dependencies = [ 1343 + "cc", 1344 + ] 1345 + 1346 + [[package]] 1347 + name = "luajit-src" 1348 + version = "210.6.6+707c12b" 1349 + source = "registry+https://github.com/rust-lang/crates.io-index" 1350 + checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" 1351 + dependencies = [ 1352 + "cc", 1353 + "which", 1354 + ] 1355 + 1356 + [[package]] 1310 1357 name = "matchers" 1311 1358 version = "0.2.0" 1312 1359 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1365 1412 ] 1366 1413 1367 1414 [[package]] 1415 + name = "mlua" 1416 + version = "0.11.6" 1417 + source = "registry+https://github.com/rust-lang/crates.io-index" 1418 + checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" 1419 + dependencies = [ 1420 + "bstr", 1421 + "either", 1422 + "erased-serde", 1423 + "futures-util", 1424 + "libc", 1425 + "mlua-sys", 1426 + "num-traits", 1427 + "parking_lot", 1428 + "rustc-hash", 1429 + "rustversion", 1430 + "serde", 1431 + "serde-value", 1432 + ] 1433 + 1434 + [[package]] 1435 + name = "mlua-sys" 1436 + version = "0.10.0" 1437 + source = "registry+https://github.com/rust-lang/crates.io-index" 1438 + checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" 1439 + dependencies = [ 1440 + "cc", 1441 + "cfg-if", 1442 + "libc", 1443 + "lua-src", 1444 + "luajit-src", 1445 + "pkg-config", 1446 + ] 1447 + 1448 + [[package]] 1368 1449 name = "moka" 1369 1450 version = "0.12.13" 1370 1451 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1531 1612 "libc", 1532 1613 "pkg-config", 1533 1614 "vcpkg", 1615 + ] 1616 + 1617 + [[package]] 1618 + name = "ordered-float" 1619 + version = "2.10.1" 1620 + source = "registry+https://github.com/rust-lang/crates.io-index" 1621 + checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" 1622 + dependencies = [ 1623 + "num-traits", 1534 1624 ] 1535 1625 1536 1626 [[package]] ··· 1908 1998 ] 1909 1999 1910 2000 [[package]] 2001 + name = "rustc-hash" 2002 + version = "2.1.1" 2003 + source = "registry+https://github.com/rust-lang/crates.io-index" 2004 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2005 + 2006 + [[package]] 1911 2007 name = "rustix" 1912 2008 version = "1.1.3" 1913 2009 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2050 2146 ] 2051 2147 2052 2148 [[package]] 2149 + name = "serde-value" 2150 + version = "0.7.0" 2151 + source = "registry+https://github.com/rust-lang/crates.io-index" 2152 + checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" 2153 + dependencies = [ 2154 + "ordered-float", 2155 + "serde", 2156 + ] 2157 + 2158 + [[package]] 2053 2159 name = "serde_core" 2054 2160 version = "1.0.228" 2055 2161 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2872 2978 ] 2873 2979 2874 2980 [[package]] 2981 + name = "typeid" 2982 + version = "1.0.3" 2983 + source = "registry+https://github.com/rust-lang/crates.io-index" 2984 + checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" 2985 + 2986 + [[package]] 2875 2987 name = "typenum" 2876 2988 version = "1.19.0" 2877 2989 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3139 3251 checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" 3140 3252 dependencies = [ 3141 3253 "rustls-pki-types", 3254 + ] 3255 + 3256 + [[package]] 3257 + name = "which" 3258 + version = "8.0.0" 3259 + source = "registry+https://github.com/rust-lang/crates.io-index" 3260 + checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" 3261 + dependencies = [ 3262 + "env_home", 3263 + "rustix", 3264 + "winsafe", 3142 3265 ] 3143 3266 3144 3267 [[package]] ··· 3458 3581 "cfg-if", 3459 3582 "windows-sys 0.48.0", 3460 3583 ] 3584 + 3585 + [[package]] 3586 + name = "winsafe" 3587 + version = "0.0.19" 3588 + source = "registry+https://github.com/rust-lang/crates.io-index" 3589 + checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" 3461 3590 3462 3591 [[package]] 3463 3592 name = "wiremock"
+1
Cargo.toml
··· 22 22 tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } 23 23 tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } 24 24 hickory-resolver = "0.25" 25 + mlua = { version = "0.11", features = ["lua54", "async", "serialize", "vendored", "send"] } 25 26 tracing = "0.1" 26 27 tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 27 28
+1
migrations/20260222000000_add_lexicon_script.sql
··· 1 + ALTER TABLE lexicons ADD COLUMN script TEXT;
+53 -12
src/admin/lexicons.rs
··· 55 55 1, 56 56 body.target_collection.clone(), 57 57 action.clone(), 58 + body.script.clone(), 58 59 ) 59 60 .map_err(|e| AppError::BadRequest(format!("failed to parse lexicon: {e}")))?; 60 61 62 + // Validate script if provided 63 + if let Some(ref script) = body.script { 64 + crate::lua::validate_script(script).map_err(AppError::BadRequest)?; 65 + } 66 + 61 67 let action_str = action.to_optional_str(); 62 68 63 69 // Upsert into database 64 70 let row: (i32,) = sqlx::query_as( 65 71 r#" 66 - INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, source) 67 - VALUES ($1, $2, $3, $4, $5, 'manual') 72 + INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, script, source) 73 + VALUES ($1, $2, $3, $4, $5, $6, 'manual') 68 74 ON CONFLICT (id) DO UPDATE SET 69 75 lexicon_json = EXCLUDED.lexicon_json, 70 76 backfill = EXCLUDED.backfill, 71 77 target_collection = EXCLUDED.target_collection, 72 78 action = EXCLUDED.action, 79 + script = EXCLUDED.script, 73 80 source = 'manual', 74 81 revision = lexicons.revision + 1, 75 82 updated_at = NOW() ··· 81 88 .bind(body.backfill) 82 89 .bind(&body.target_collection) 83 90 .bind(action_str) 91 + .bind(&body.script) 84 92 .fetch_one(&state.db) 85 93 .await 86 94 .map_err(|e| AppError::Internal(format!("failed to upsert lexicon: {e}")))?; ··· 88 96 let revision = row.0; 89 97 90 98 // Update in-memory registry with correct revision 91 - let parsed = ParsedLexicon::parse(body.lexicon_json, revision, body.target_collection, action) 92 - .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; 99 + let parsed = ParsedLexicon::parse( 100 + body.lexicon_json, 101 + revision, 102 + body.target_collection, 103 + action, 104 + body.script, 105 + ) 106 + .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; 93 107 let is_record = parsed.lexicon_type == LexiconType::Record; 94 108 state.lexicons.upsert(parsed).await; 95 109 ··· 118 132 _admin: AdminAuth, 119 133 ) -> Result<Json<Vec<LexiconSummary>>, AppError> { 120 134 #[allow(clippy::type_complexity)] 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>)> = 135 + let rows: Vec<(String, i32, Value, bool, Option<String>, Option<String>, Option<String>, String, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 122 136 sqlx::query_as( 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", 137 + "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons ORDER BY id", 124 138 ) 125 139 .fetch_all(&state.db) 126 140 .await ··· 136 150 backfill, 137 151 action, 138 152 target_collection, 153 + script, 139 154 source, 140 155 authority_did, 141 156 last_fetched_at, 142 157 created_at, 143 158 updated_at, 144 159 )| { 145 - let lexicon_type = 146 - ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert) 147 - .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) 148 - .unwrap_or_else(|_| "unknown".into()); 160 + let parsed = 161 + ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert, None); 162 + let lexicon_type = parsed 163 + .as_ref() 164 + .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) 165 + .unwrap_or_else(|_| "unknown".into()); 166 + let record_schema = parsed 167 + .ok() 168 + .filter(|p| p.lexicon_type == LexiconType::Record) 169 + .and_then(|p| p.record_schema); 149 170 150 171 LexiconSummary { 151 172 id, ··· 154 175 backfill, 155 176 action, 156 177 target_collection, 178 + has_script: script.is_some(), 157 179 source, 158 180 authority_did, 159 181 last_fetched_at, 160 182 created_at, 161 183 updated_at, 184 + record_schema, 162 185 } 163 186 }, 164 187 ) ··· 174 197 Path(id): Path<String>, 175 198 ) -> Result<Json<Value>, AppError> { 176 199 #[allow(clippy::type_complexity)] 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>)> = 200 + let row: Option<(String, i32, Value, bool, Option<String>, Option<String>, Option<String>, String, Option<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 178 201 sqlx::query_as( 179 - "SELECT id, revision, lexicon_json, backfill, action, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons WHERE id = $1", 202 + "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, source, authority_did, last_fetched_at, created_at, updated_at FROM lexicons WHERE id = $1", 180 203 ) 181 204 .bind(&id) 182 205 .fetch_optional(&state.db) ··· 189 212 lexicon_json, 190 213 backfill, 191 214 action, 215 + target_collection, 216 + script, 192 217 source, 193 218 authority_did, 194 219 last_fetched_at, ··· 196 221 updated_at, 197 222 ) = row.ok_or_else(|| AppError::NotFound(format!("lexicon '{id}' not found")))?; 198 223 224 + let lexicon_type = ParsedLexicon::parse( 225 + lexicon_json.clone(), 226 + revision, 227 + None, 228 + ProcedureAction::Upsert, 229 + None, 230 + ) 231 + .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) 232 + .unwrap_or_else(|_| "unknown".into()); 233 + 234 + let has_script = script.is_some(); 235 + 199 236 Ok(Json(serde_json::json!({ 200 237 "id": id, 201 238 "revision": revision, 202 239 "lexicon_json": lexicon_json, 240 + "lexicon_type": lexicon_type, 203 241 "backfill": backfill, 204 242 "action": action, 243 + "target_collection": target_collection, 244 + "has_script": has_script, 245 + "script": script, 205 246 "source": source, 206 247 "authority_did": authority_did, 207 248 "last_fetched_at": last_fetched_at,
+2
src/admin/network_lexicons.rs
··· 40 40 1, 41 41 body.target_collection.clone(), 42 42 ProcedureAction::Upsert, 43 + None, 43 44 ) 44 45 .map_err(|e| AppError::BadRequest(format!("failed to parse lexicon: {e}")))?; 45 46 ··· 76 77 revision, 77 78 body.target_collection, 78 79 ProcedureAction::Upsert, 80 + None, 79 81 ) 80 82 .map_err(|e| AppError::Internal(format!("failed to re-parse lexicon: {e}")))?; 81 83 state.lexicons.upsert(parsed).await;
+5
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) has_script: bool, 16 17 pub(super) source: String, 17 18 pub(super) authority_did: Option<String>, 18 19 pub(super) last_fetched_at: Option<chrono::DateTime<chrono::Utc>>, 19 20 pub(super) created_at: chrono::DateTime<chrono::Utc>, 20 21 pub(super) updated_at: chrono::DateTime<chrono::Utc>, 22 + /// For record-type lexicons: the `properties` object from `defs.main.record`. 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub(super) record_schema: Option<Value>, 21 25 } 22 26 23 27 #[derive(Deserialize)] ··· 27 31 pub(super) backfill: bool, 28 32 pub(super) target_collection: Option<String>, 29 33 pub(super) action: Option<String>, 34 + pub(super) script: Option<String>, 30 35 } 31 36 32 37 fn default_backfill() -> bool {
+109 -33
src/lexicon.rs
··· 79 79 pub target_collection: Option<String>, 80 80 /// For procedures: the action this procedure performs (create, update, delete, upsert). 81 81 pub action: ProcedureAction, 82 + /// Optional Lua script that replaces the built-in handler. 83 + pub script: Option<String>, 82 84 } 83 85 84 86 impl ParsedLexicon { ··· 88 90 revision: i32, 89 91 target_collection: Option<String>, 90 92 action: ProcedureAction, 93 + script: Option<String>, 91 94 ) -> Result<Self, String> { 92 95 let id = raw 93 96 .get("id") ··· 130 133 revision, 131 134 target_collection, 132 135 action, 136 + script, 133 137 }) 134 138 } 135 139 } ··· 156 160 /// Load all lexicons from the database, replacing any existing entries. 157 161 pub async fn load_from_db(&self, db: &sqlx::PgPool) -> Result<(), String> { 158 162 #[allow(clippy::type_complexity)] 159 - let rows: Vec<(String, Value, i32, Option<String>, Option<String>)> = sqlx::query_as( 160 - "SELECT id, lexicon_json, revision, target_collection, action FROM lexicons", 163 + let rows: Vec<( 164 + String, 165 + Value, 166 + i32, 167 + Option<String>, 168 + Option<String>, 169 + Option<String>, 170 + )> = sqlx::query_as( 171 + "SELECT id, lexicon_json, revision, target_collection, action, script FROM lexicons", 161 172 ) 162 173 .fetch_all(db) 163 174 .await ··· 167 178 inner.clear(); 168 179 169 180 let mut loaded = 0u32; 170 - for (id, json, revision, target_collection, action_str) in rows { 181 + for (id, json, revision, target_collection, action_str, script) in rows { 171 182 let action = match ProcedureAction::from_optional_str(action_str.as_deref()) { 172 183 Ok(a) => a, 173 184 Err(e) => { ··· 175 186 ProcedureAction::Upsert 176 187 } 177 188 }; 178 - match ParsedLexicon::parse(json, revision, target_collection, action) { 189 + match ParsedLexicon::parse(json, revision, target_collection, action, script) { 179 190 Ok(parsed) => { 180 191 inner.insert(id, parsed); 181 192 loaded += 1; ··· 327 338 328 339 #[test] 329 340 fn parse_record_lexicon() { 330 - let parsed = 331 - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 341 + let parsed = ParsedLexicon::parse( 342 + record_lexicon_json(), 343 + 1, 344 + None, 345 + ProcedureAction::Upsert, 346 + None, 347 + ) 348 + .unwrap(); 332 349 assert_eq!(parsed.id, "games.gamesgamesgamesgames.game"); 333 350 assert_eq!(parsed.lexicon_type, LexiconType::Record); 334 351 assert_eq!(parsed.record_key, Some("tid".into())); ··· 344 361 2, 345 362 Some("games.gamesgamesgamesgames.game".into()), 346 363 ProcedureAction::Upsert, 364 + None, 347 365 ) 348 366 .unwrap(); 349 367 assert_eq!(parsed.lexicon_type, LexiconType::Query); ··· 358 376 359 377 #[test] 360 378 fn parse_procedure_lexicon() { 361 - let parsed = 362 - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Upsert) 363 - .unwrap(); 379 + let parsed = ParsedLexicon::parse( 380 + procedure_lexicon_json(), 381 + 1, 382 + None, 383 + ProcedureAction::Upsert, 384 + None, 385 + ) 386 + .unwrap(); 364 387 assert_eq!(parsed.lexicon_type, LexiconType::Procedure); 365 388 assert!(parsed.input.is_some()); 366 389 assert!(parsed.output.is_some()); ··· 368 391 369 392 #[test] 370 393 fn parse_procedure_with_action() { 371 - let parsed = 372 - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Delete) 373 - .unwrap(); 394 + let parsed = ParsedLexicon::parse( 395 + procedure_lexicon_json(), 396 + 1, 397 + None, 398 + ProcedureAction::Delete, 399 + None, 400 + ) 401 + .unwrap(); 374 402 assert_eq!(parsed.action, ProcedureAction::Delete); 375 403 } 376 404 377 405 #[test] 378 406 fn parse_definitions_lexicon() { 379 - let parsed = 380 - ParsedLexicon::parse(definitions_lexicon_json(), 1, None, ProcedureAction::Upsert) 381 - .unwrap(); 407 + let parsed = ParsedLexicon::parse( 408 + definitions_lexicon_json(), 409 + 1, 410 + None, 411 + ProcedureAction::Upsert, 412 + None, 413 + ) 414 + .unwrap(); 382 415 assert_eq!(parsed.lexicon_type, LexiconType::Definitions); 383 416 } 384 417 385 418 #[test] 386 419 fn parse_missing_id_returns_error() { 387 420 let raw = json!({"lexicon": 1, "defs": {}}); 388 - let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert); 421 + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); 389 422 assert!(result.is_err()); 390 423 assert!(result.unwrap_err().contains("id")); 391 424 } ··· 393 426 #[test] 394 427 fn parse_preserves_raw_json() { 395 428 let raw = record_lexicon_json(); 396 - let parsed = ParsedLexicon::parse(raw.clone(), 1, None, ProcedureAction::Upsert).unwrap(); 429 + let parsed = 430 + ParsedLexicon::parse(raw.clone(), 1, None, ProcedureAction::Upsert, None).unwrap(); 397 431 assert_eq!(parsed.raw, raw); 398 432 } 399 433 ··· 404 438 1, 405 439 Some("custom.collection".into()), 406 440 ProcedureAction::Upsert, 441 + None, 407 442 ) 408 443 .unwrap(); 409 444 assert_eq!(parsed.target_collection, Some("custom.collection".into())); ··· 422 457 #[tokio::test] 423 458 async fn registry_upsert_and_get() { 424 459 let reg = LexiconRegistry::new(); 425 - let parsed = 426 - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 460 + let parsed = ParsedLexicon::parse( 461 + record_lexicon_json(), 462 + 1, 463 + None, 464 + ProcedureAction::Upsert, 465 + None, 466 + ) 467 + .unwrap(); 427 468 reg.upsert(parsed).await; 428 469 429 470 let got = reg.get("games.gamesgamesgamesgames.game").await; ··· 434 475 #[tokio::test] 435 476 async fn registry_upsert_replaces() { 436 477 let reg = LexiconRegistry::new(); 437 - let v1 = 438 - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 478 + let v1 = ParsedLexicon::parse( 479 + record_lexicon_json(), 480 + 1, 481 + None, 482 + ProcedureAction::Upsert, 483 + None, 484 + ) 485 + .unwrap(); 439 486 reg.upsert(v1).await; 440 487 441 - let v2 = 442 - ParsedLexicon::parse(record_lexicon_json(), 5, None, ProcedureAction::Upsert).unwrap(); 488 + let v2 = ParsedLexicon::parse( 489 + record_lexicon_json(), 490 + 5, 491 + None, 492 + ProcedureAction::Upsert, 493 + None, 494 + ) 495 + .unwrap(); 443 496 reg.upsert(v2).await; 444 497 445 498 assert_eq!(reg.count().await, 1); ··· 455 508 #[tokio::test] 456 509 async fn registry_remove_existing() { 457 510 let reg = LexiconRegistry::new(); 458 - let parsed = 459 - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 511 + let parsed = ParsedLexicon::parse( 512 + record_lexicon_json(), 513 + 1, 514 + None, 515 + ProcedureAction::Upsert, 516 + None, 517 + ) 518 + .unwrap(); 460 519 reg.upsert(parsed).await; 461 520 462 521 assert!(reg.remove("games.gamesgamesgamesgames.game").await); ··· 479 538 async fn registry_type_filtered_collections() { 480 539 let reg = LexiconRegistry::new(); 481 540 482 - let record = 483 - ParsedLexicon::parse(record_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 541 + let record = ParsedLexicon::parse( 542 + record_lexicon_json(), 543 + 1, 544 + None, 545 + ProcedureAction::Upsert, 546 + None, 547 + ) 548 + .unwrap(); 484 549 let query = 485 - ParsedLexicon::parse(query_lexicon_json(), 1, None, ProcedureAction::Upsert).unwrap(); 486 - let procedure = 487 - ParsedLexicon::parse(procedure_lexicon_json(), 1, None, ProcedureAction::Upsert) 550 + ParsedLexicon::parse(query_lexicon_json(), 1, None, ProcedureAction::Upsert, None) 488 551 .unwrap(); 489 - let defs = 490 - ParsedLexicon::parse(definitions_lexicon_json(), 1, None, ProcedureAction::Upsert) 491 - .unwrap(); 552 + let procedure = ParsedLexicon::parse( 553 + procedure_lexicon_json(), 554 + 1, 555 + None, 556 + ProcedureAction::Upsert, 557 + None, 558 + ) 559 + .unwrap(); 560 + let defs = ParsedLexicon::parse( 561 + definitions_lexicon_json(), 562 + 1, 563 + None, 564 + ProcedureAction::Upsert, 565 + None, 566 + ) 567 + .unwrap(); 492 568 493 569 reg.upsert(record).await; 494 570 reg.upsert(query).await;
+1
src/lib.rs
··· 4 4 pub mod config; 5 5 pub mod error; 6 6 pub mod lexicon; 7 + pub mod lua; 7 8 pub mod profile; 8 9 pub mod repo; 9 10 pub mod resolve;
+33
src/lua/context.rs
··· 1 + use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; 2 + use serde_json::Value; 3 + use std::collections::HashMap; 4 + 5 + /// Set global context variables for a procedure script. 6 + pub fn set_procedure_context( 7 + lua: &Lua, 8 + method: &str, 9 + input: &Value, 10 + caller_did: &str, 11 + collection: &str, 12 + ) -> LuaResult<()> { 13 + let globals = lua.globals(); 14 + globals.set("method", method.to_string())?; 15 + globals.set("input", lua.to_value(input)?)?; 16 + globals.set("caller_did", caller_did.to_string())?; 17 + globals.set("collection", collection.to_string())?; 18 + Ok(()) 19 + } 20 + 21 + /// Set global context variables for a query script. 22 + pub fn set_query_context( 23 + lua: &Lua, 24 + method: &str, 25 + params: &HashMap<String, String>, 26 + collection: &str, 27 + ) -> LuaResult<()> { 28 + let globals = lua.globals(); 29 + globals.set("method", method.to_string())?; 30 + globals.set("params", lua.to_value(params)?)?; 31 + globals.set("collection", collection.to_string())?; 32 + Ok(()) 33 + }
+119
src/lua/db_api.rs
··· 1 + use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; 2 + use serde_json::{Value, json}; 3 + use std::sync::Arc; 4 + 5 + use crate::AppState; 6 + 7 + /// Register the `db` table with read-only database query functions. 8 + pub fn register_db_api(lua: &Lua, state: Arc<AppState>) -> LuaResult<()> { 9 + let db_table = lua.create_table()?; 10 + 11 + // db.query({ collection, did?, limit?, offset? }) -> { records, cursor? } 12 + let state_query = state.clone(); 13 + let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { 14 + let state = state_query.clone(); 15 + async move { 16 + let collection: String = opts.get("collection")?; 17 + let did: Option<String> = opts.get("did").ok(); 18 + let limit: i64 = opts.get::<i64>("limit").unwrap_or(20).min(100); 19 + let offset: i64 = opts.get::<i64>("offset").unwrap_or(0); 20 + 21 + let rows: Vec<(String, String, Value)> = if let Some(ref did) = did { 22 + sqlx::query_as( 23 + "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 ORDER BY indexed_at DESC LIMIT $3 OFFSET $4", 24 + ) 25 + .bind(&collection) 26 + .bind(did) 27 + .bind(limit) 28 + .bind(offset) 29 + .fetch_all(&state.db) 30 + .await 31 + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? 32 + } else { 33 + sqlx::query_as( 34 + "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3", 35 + ) 36 + .bind(&collection) 37 + .bind(limit) 38 + .bind(offset) 39 + .fetch_all(&state.db) 40 + .await 41 + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? 42 + }; 43 + 44 + let has_next = rows.len() as i64 == limit; 45 + let records: Vec<Value> = rows 46 + .into_iter() 47 + .map(|(uri, _did, mut record)| { 48 + if let Some(obj) = record.as_object_mut() { 49 + obj.insert("uri".to_string(), json!(uri)); 50 + } 51 + record 52 + }) 53 + .collect(); 54 + 55 + let mut result = json!({ "records": records }); 56 + if has_next { 57 + let next_cursor = (offset + limit).to_string(); 58 + result.as_object_mut().unwrap().insert("cursor".to_string(), json!(next_cursor)); 59 + } 60 + 61 + lua.to_value(&result) 62 + } 63 + })?; 64 + db_table.set("query", query_fn)?; 65 + 66 + // db.get(uri) -> record table or nil 67 + let state_get = state.clone(); 68 + let get_fn = lua.create_async_function(move |lua, uri: String| { 69 + let state = state_get.clone(); 70 + async move { 71 + let row: Option<(Value,)> = sqlx::query_as("SELECT record FROM records WHERE uri = $1") 72 + .bind(&uri) 73 + .fetch_optional(&state.db) 74 + .await 75 + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; 76 + 77 + match row { 78 + Some((mut record,)) => { 79 + if let Some(obj) = record.as_object_mut() { 80 + obj.insert("uri".to_string(), json!(uri)); 81 + } 82 + lua.to_value(&record) 83 + } 84 + None => Ok(mlua::Value::Nil), 85 + } 86 + } 87 + })?; 88 + db_table.set("get", get_fn)?; 89 + 90 + // db.count(collection, did?) -> integer 91 + let state_count = state; 92 + let count_fn = 93 + lua.create_async_function(move |_, (collection, did): (String, Option<String>)| { 94 + let state = state_count.clone(); 95 + async move { 96 + let count: (i64,) = if let Some(ref did) = did { 97 + sqlx::query_as( 98 + "SELECT COUNT(*) FROM records WHERE collection = $1 AND did = $2", 99 + ) 100 + .bind(&collection) 101 + .bind(did) 102 + .fetch_one(&state.db) 103 + .await 104 + .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? 105 + } else { 106 + sqlx::query_as("SELECT COUNT(*) FROM records WHERE collection = $1") 107 + .bind(&collection) 108 + .fetch_one(&state.db) 109 + .await 110 + .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? 111 + }; 112 + Ok(count.0) 113 + } 114 + })?; 115 + db_table.set("count", count_fn)?; 116 + 117 + lua.globals().set("db", db_table)?; 118 + Ok(()) 119 + }
+123
src/lua/execute.rs
··· 1 + use axum::Json; 2 + use axum::response::{IntoResponse, Response}; 3 + use mlua::LuaSerdeExt; 4 + use serde_json::Value; 5 + use std::collections::HashMap; 6 + use std::sync::Arc; 7 + 8 + use crate::AppState; 9 + use crate::auth::Claims; 10 + use crate::error::AppError; 11 + use crate::lexicon::ParsedLexicon; 12 + use crate::repo; 13 + 14 + use super::context; 15 + use super::db_api; 16 + use super::record; 17 + use super::sandbox; 18 + 19 + /// Execute a Lua script for a procedure endpoint. 20 + pub async fn execute_procedure_script( 21 + state: &AppState, 22 + method: &str, 23 + claims: &Claims, 24 + input: &Value, 25 + lexicon: &ParsedLexicon, 26 + script: &str, 27 + ) -> Result<Response, AppError> { 28 + let collection = lexicon.target_collection.as_deref().unwrap_or_default(); 29 + 30 + let session = repo::get_atp_session(state, claims.token()).await?; 31 + 32 + let lua = sandbox::create_sandbox() 33 + .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?; 34 + 35 + let state_arc = Arc::new(state.clone()); 36 + let claims_arc = Arc::new(claims.clone()); 37 + let session_arc = Arc::new(session); 38 + 39 + db_api::register_db_api(&lua, state_arc.clone()) 40 + .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?; 41 + 42 + record::register_record_api(&lua, state_arc, claims_arc, session_arc) 43 + .map_err(|e| AppError::Internal(format!("failed to register Record API: {e}")))?; 44 + 45 + context::set_procedure_context(&lua, method, input, claims.did(), collection) 46 + .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?; 47 + 48 + lua.load(script).exec().map_err(|e| { 49 + tracing::error!(method, error = %e, "lua script load failed"); 50 + AppError::Internal("script execution failed".into()) 51 + })?; 52 + 53 + let handle: mlua::Function = lua.globals().get("handle").map_err(|e| { 54 + tracing::error!(method, error = %e, "lua script missing handle function"); 55 + AppError::Internal("script execution failed".into()) 56 + })?; 57 + 58 + let result: mlua::Value = handle.call_async(()).await.map_err(|e| { 59 + let msg = e.to_string(); 60 + tracing::error!(method, error = %msg, "lua script execution failed"); 61 + if msg.contains("execution limit") { 62 + AppError::Internal("script exceeded execution time limit".into()) 63 + } else { 64 + AppError::Internal("script execution failed".into()) 65 + } 66 + })?; 67 + 68 + let json_value: Value = lua.from_value(result).map_err(|e| { 69 + tracing::error!(method, error = %e, "failed to convert lua result to JSON"); 70 + AppError::Internal("script execution failed".into()) 71 + })?; 72 + 73 + Ok(Json(json_value).into_response()) 74 + } 75 + 76 + /// Execute a Lua script for a query endpoint. 77 + pub async fn execute_query_script( 78 + state: &AppState, 79 + method: &str, 80 + params: &HashMap<String, String>, 81 + lexicon: &ParsedLexicon, 82 + script: &str, 83 + ) -> Result<Response, AppError> { 84 + let collection = lexicon.target_collection.as_deref().unwrap_or_default(); 85 + 86 + let lua = sandbox::create_sandbox() 87 + .map_err(|e| AppError::Internal(format!("failed to create Lua VM: {e}")))?; 88 + 89 + let state_arc = Arc::new(state.clone()); 90 + 91 + db_api::register_db_api(&lua, state_arc) 92 + .map_err(|e| AppError::Internal(format!("failed to register db API: {e}")))?; 93 + 94 + context::set_query_context(&lua, method, params, collection) 95 + .map_err(|e| AppError::Internal(format!("failed to set context: {e}")))?; 96 + 97 + lua.load(script).exec().map_err(|e| { 98 + tracing::error!(method, error = %e, "lua script load failed"); 99 + AppError::Internal("script execution failed".into()) 100 + })?; 101 + 102 + let handle: mlua::Function = lua.globals().get("handle").map_err(|e| { 103 + tracing::error!(method, error = %e, "lua script missing handle function"); 104 + AppError::Internal("script execution failed".into()) 105 + })?; 106 + 107 + let result: mlua::Value = handle.call_async(()).await.map_err(|e| { 108 + let msg = e.to_string(); 109 + tracing::error!(method, error = %msg, "lua script execution failed"); 110 + if msg.contains("execution limit") { 111 + AppError::Internal("script exceeded execution time limit".into()) 112 + } else { 113 + AppError::Internal("script execution failed".into()) 114 + } 115 + })?; 116 + 117 + let json_value: Value = lua.from_value(result).map_err(|e| { 118 + tracing::error!(method, error = %e, "failed to convert lua result to JSON"); 119 + AppError::Internal("script execution failed".into()) 120 + })?; 121 + 122 + Ok(Json(json_value).into_response()) 123 + }
+9
src/lua/mod.rs
··· 1 + mod context; 2 + mod db_api; 3 + mod execute; 4 + mod record; 5 + pub(crate) mod sandbox; 6 + mod tid; 7 + 8 + pub(crate) use execute::{execute_procedure_script, execute_query_script}; 9 + pub(crate) use sandbox::validate_script;
+840
src/lua/record.rs
··· 1 + use futures_util::future::try_join_all; 2 + use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; 3 + use serde_json::{Value, json}; 4 + use std::sync::Arc; 5 + 6 + use crate::AppState; 7 + use crate::auth::Claims; 8 + use crate::repo::{self, AtpSession}; 9 + 10 + use super::tid::generate_tid; 11 + 12 + const INTERNAL_FIELDS: &[&str] = &[ 13 + "_collection", 14 + "_uri", 15 + "_cid", 16 + "_schema", 17 + "_key_type", 18 + "_rkey", 19 + ]; 20 + 21 + /// Register the `Record` global constructor and static methods. 22 + /// Only registered for procedure scripts (not queries). 23 + pub fn register_record_api( 24 + lua: &Lua, 25 + state: Arc<AppState>, 26 + claims: Arc<Claims>, 27 + session: Arc<AtpSession>, 28 + ) -> LuaResult<()> { 29 + // -- methods table (shared by all Record instances) -- 30 + let methods = lua.create_table()?; 31 + 32 + // :save() 33 + { 34 + let state = state.clone(); 35 + let claims = claims.clone(); 36 + let session = session.clone(); 37 + let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { 38 + let state = state.clone(); 39 + let claims = claims.clone(); 40 + let session = session.clone(); 41 + async move { 42 + let collection: String = this.raw_get("_collection")?; 43 + let schema: mlua::Value = this.raw_get("_schema")?; 44 + 45 + // Validate required fields against schema 46 + if let mlua::Value::Table(ref schema_table) = schema { 47 + validate_required_fields(&this, schema_table)?; 48 + } 49 + 50 + // Serialize record data (skip _ keys, inject $type) 51 + let data = extract_record_data(&lua, &this, &collection)?; 52 + 53 + let existing_uri: Option<String> = this.raw_get("_uri")?; 54 + 55 + let pds_result = if let Some(ref uri) = existing_uri { 56 + // PUT 57 + let rkey = uri 58 + .split('/') 59 + .next_back() 60 + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? 61 + .to_string(); 62 + 63 + let pds_body = json!({ 64 + "repo": claims.did(), 65 + "collection": collection, 66 + "rkey": rkey, 67 + "record": data, 68 + }); 69 + 70 + let resp = repo::pds_post_json_raw( 71 + &state, 72 + &session, 73 + "com.atproto.repo.putRecord", 74 + &pds_body, 75 + ) 76 + .await 77 + .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?; 78 + 79 + if !resp.status().is_success() { 80 + let status = resp.status(); 81 + let body = resp.text().await.unwrap_or_default(); 82 + return Err(mlua::Error::runtime(format!( 83 + "PDS putRecord returned {status}: {body}" 84 + ))); 85 + } 86 + 87 + let bytes = resp.bytes().await.map_err(|e| { 88 + mlua::Error::runtime(format!("failed to read PDS response: {e}")) 89 + })?; 90 + let result: Value = serde_json::from_slice(&bytes) 91 + .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?; 92 + 93 + // Upsert local DB 94 + let cid = result 95 + .get("cid") 96 + .and_then(|v| v.as_str()) 97 + .unwrap_or_default(); 98 + let _ = sqlx::query( 99 + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) 100 + VALUES ($1, $2, $3, $4, $5, $6) 101 + ON CONFLICT (uri) DO UPDATE 102 + SET record = EXCLUDED.record, 103 + cid = EXCLUDED.cid, 104 + indexed_at = NOW()"#, 105 + ) 106 + .bind(uri) 107 + .bind(claims.did()) 108 + .bind(&collection) 109 + .bind(&rkey) 110 + .bind(&data) 111 + .bind(cid) 112 + .execute(&state.db) 113 + .await; 114 + 115 + result 116 + } else { 117 + // CREATE 118 + let rkey: Option<String> = this.raw_get("_rkey")?; 119 + let mut pds_body = json!({ 120 + "repo": claims.did(), 121 + "collection": collection, 122 + "record": data, 123 + }); 124 + if let Some(ref rkey) = rkey { 125 + pds_body["rkey"] = json!(rkey); 126 + } 127 + 128 + let resp = repo::pds_post_json_raw( 129 + &state, 130 + &session, 131 + "com.atproto.repo.createRecord", 132 + &pds_body, 133 + ) 134 + .await 135 + .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?; 136 + 137 + if !resp.status().is_success() { 138 + let status = resp.status(); 139 + let body = resp.text().await.unwrap_or_default(); 140 + return Err(mlua::Error::runtime(format!( 141 + "PDS createRecord returned {status}: {body}" 142 + ))); 143 + } 144 + 145 + let bytes = resp.bytes().await.map_err(|e| { 146 + mlua::Error::runtime(format!("failed to read PDS response: {e}")) 147 + })?; 148 + let result: Value = serde_json::from_slice(&bytes) 149 + .map_err(|e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")))?; 150 + 151 + // Upsert local DB 152 + if let (Some(uri), Some(cid)) = ( 153 + result.get("uri").and_then(|v| v.as_str()), 154 + result.get("cid").and_then(|v| v.as_str()), 155 + ) { 156 + let rkey = uri.split('/').next_back().unwrap_or_default(); 157 + let _ = sqlx::query( 158 + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) 159 + VALUES ($1, $2, $3, $4, $5, $6) 160 + ON CONFLICT (uri) DO UPDATE 161 + SET record = EXCLUDED.record, 162 + cid = EXCLUDED.cid"#, 163 + ) 164 + .bind(uri) 165 + .bind(claims.did()) 166 + .bind(&collection) 167 + .bind(rkey) 168 + .bind(&data) 169 + .bind(cid) 170 + .execute(&state.db) 171 + .await; 172 + } 173 + 174 + result 175 + }; 176 + 177 + // Write back _uri and _cid 178 + if let Some(uri) = pds_result.get("uri").and_then(|v| v.as_str()) { 179 + this.raw_set("_uri", uri.to_string())?; 180 + } 181 + if let Some(cid) = pds_result.get("cid").and_then(|v| v.as_str()) { 182 + this.raw_set("_cid", cid.to_string())?; 183 + } 184 + 185 + Ok(this) 186 + } 187 + })?; 188 + methods.set("save", save_fn)?; 189 + } 190 + 191 + // :delete() 192 + { 193 + let state = state.clone(); 194 + let claims = claims.clone(); 195 + let session = session.clone(); 196 + let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { 197 + let state = state.clone(); 198 + let claims = claims.clone(); 199 + let session = session.clone(); 200 + async move { 201 + let uri: String = this.raw_get::<Option<String>>("_uri")?.ok_or_else(|| { 202 + mlua::Error::runtime("cannot delete a Record that has no _uri") 203 + })?; 204 + let collection: String = this.raw_get("_collection")?; 205 + 206 + let rkey = uri 207 + .split('/') 208 + .next_back() 209 + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? 210 + .to_string(); 211 + 212 + let pds_body = json!({ 213 + "repo": claims.did(), 214 + "collection": collection, 215 + "rkey": rkey, 216 + }); 217 + 218 + let resp = repo::pds_post_json_raw( 219 + &state, 220 + &session, 221 + "com.atproto.repo.deleteRecord", 222 + &pds_body, 223 + ) 224 + .await 225 + .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?; 226 + 227 + if !resp.status().is_success() { 228 + let status = resp.status(); 229 + let body = resp.text().await.unwrap_or_default(); 230 + return Err(mlua::Error::runtime(format!( 231 + "PDS deleteRecord returned {status}: {body}" 232 + ))); 233 + } 234 + 235 + // Delete from local DB 236 + let _ = sqlx::query("DELETE FROM records WHERE uri = $1") 237 + .bind(&uri) 238 + .execute(&state.db) 239 + .await; 240 + 241 + // Clear _uri and _cid 242 + this.raw_set("_uri", mlua::Value::Nil)?; 243 + this.raw_set("_cid", mlua::Value::Nil)?; 244 + 245 + Ok(this) 246 + } 247 + })?; 248 + methods.set("delete", delete_fn)?; 249 + } 250 + 251 + // :set_key_type(type) 252 + { 253 + let set_key_type_fn = 254 + lua.create_function(|_lua, (this, key_type): (mlua::Table, String)| { 255 + match key_type.as_str() { 256 + "tid" | "any" | "nsid" => {} 257 + s if s.starts_with("literal:") && s.len() > "literal:".len() => {} 258 + _ => { 259 + return Err(mlua::Error::runtime(format!( 260 + "invalid key type '{key_type}': expected tid, any, nsid, or literal:*" 261 + ))); 262 + } 263 + } 264 + this.raw_set("_key_type", key_type)?; 265 + Ok(this) 266 + })?; 267 + methods.set("set_key_type", set_key_type_fn)?; 268 + } 269 + 270 + // :set_rkey(key) 271 + { 272 + let set_rkey_fn = lua.create_function(|_lua, (this, key): (mlua::Table, String)| { 273 + if key.is_empty() { 274 + return Err(mlua::Error::runtime("rkey must be a non-empty string")); 275 + } 276 + this.raw_set("_rkey", key)?; 277 + Ok(this) 278 + })?; 279 + methods.set("set_rkey", set_rkey_fn)?; 280 + } 281 + 282 + // :generate_rkey() 283 + { 284 + let generate_rkey_fn = lua.create_function(|_lua, this: mlua::Table| { 285 + let key_type: Option<String> = this.raw_get("_key_type")?; 286 + let rkey = match key_type.as_deref() { 287 + Some("tid") | Some("any") => generate_tid(), 288 + Some(s) if s.starts_with("literal:") => s["literal:".len()..].to_string(), 289 + Some("nsid") => { 290 + return Err(mlua::Error::runtime( 291 + "cannot auto-generate rkey for nsid key type — use set_rkey() instead", 292 + )); 293 + } 294 + Some(other) => { 295 + return Err(mlua::Error::runtime(format!("unknown key type '{other}'"))); 296 + } 297 + None => { 298 + return Err(mlua::Error::runtime( 299 + "no _key_type set — call set_key_type() first or use a record-type lexicon", 300 + )); 301 + } 302 + }; 303 + this.raw_set("_rkey", rkey.as_str())?; 304 + Ok(rkey) 305 + })?; 306 + methods.set("generate_rkey", generate_rkey_fn)?; 307 + } 308 + 309 + // -- metatable -- 310 + let metatable = lua.create_table()?; 311 + 312 + // __index: check methods first, then rawget 313 + { 314 + let methods_ref = methods.clone(); 315 + let index_fn = lua.create_function(move |_lua, (this, key): (mlua::Table, String)| { 316 + // Check methods table first 317 + let method: mlua::Value = methods_ref.raw_get(key.as_str())?; 318 + if !method.is_nil() { 319 + return Ok(method); 320 + } 321 + // Fall through to raw field access 322 + this.raw_get::<mlua::Value>(key.as_str()) 323 + })?; 324 + metatable.set("__index", index_fn)?; 325 + } 326 + 327 + // __newindex: block writes to internal fields 328 + { 329 + let newindex_fn = lua.create_function( 330 + move |_lua, (this, key, value): (mlua::Table, String, mlua::Value)| { 331 + if INTERNAL_FIELDS.contains(&key.as_str()) { 332 + return Err(mlua::Error::runtime(format!( 333 + "cannot assign to internal field '{key}'" 334 + ))); 335 + } 336 + this.raw_set(key, value)?; 337 + Ok(()) 338 + }, 339 + )?; 340 + metatable.set("__newindex", newindex_fn)?; 341 + } 342 + 343 + // __tostring 344 + { 345 + let tostring_fn = lua.create_function(|_lua, this: mlua::Table| { 346 + let collection: String = this.raw_get("_collection")?; 347 + let uri: Option<String> = this.raw_get("_uri")?; 348 + match uri { 349 + Some(u) => Ok(format!("Record({collection}) [uri={u}]")), 350 + None => Ok(format!("Record({collection}) [unsaved]")), 351 + } 352 + })?; 353 + metatable.set("__tostring", tostring_fn)?; 354 + } 355 + 356 + // -- Record constructor function -- 357 + let record_table = lua.create_table()?; 358 + 359 + { 360 + let state_c = state.clone(); 361 + let metatable_c = metatable.clone(); 362 + let constructor = lua.create_async_function( 363 + move |lua, (collection, data): (String, Option<mlua::Value>)| { 364 + let state = state_c.clone(); 365 + let metatable = metatable_c.clone(); 366 + async move { 367 + let table = lua.create_table()?; 368 + 369 + // Look up schema 370 + let lexicon = state.lexicons.get(&collection).await; 371 + let schema_value: mlua::Value = 372 + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { 373 + Some(schema_json) => lua.to_value(schema_json)?, 374 + None => mlua::Value::Nil, 375 + }; 376 + 377 + // Set internal fields 378 + table.raw_set("_collection", collection.as_str())?; 379 + table.raw_set("_uri", mlua::Value::Nil)?; 380 + table.raw_set("_cid", mlua::Value::Nil)?; 381 + table.raw_set("_schema", schema_value.clone())?; 382 + 383 + // Auto-set _key_type from the lexicon's record_key 384 + match lexicon.as_ref().and_then(|l| l.record_key.as_deref()) { 385 + Some(key) => table.raw_set("_key_type", key)?, 386 + None => table.raw_set("_key_type", mlua::Value::Nil)?, 387 + } 388 + table.raw_set("_rkey", mlua::Value::Nil)?; 389 + 390 + // Copy fields from data if provided 391 + if let Some(mlua::Value::Table(data_table)) = data { 392 + for pair in data_table.pairs::<mlua::Value, mlua::Value>() { 393 + let (k, v) = pair?; 394 + table.raw_set(k, v)?; 395 + } 396 + } 397 + 398 + // Populate defaults from schema 399 + if let mlua::Value::Table(ref schema_table) = schema_value { 400 + populate_defaults(&lua, &table, schema_table)?; 401 + } 402 + 403 + table.set_metatable(Some(metatable))?; 404 + Ok(table) 405 + } 406 + }, 407 + )?; 408 + record_table.set("new", constructor)?; 409 + } 410 + 411 + // -- Static methods -- 412 + 413 + // Record.save_all(records) 414 + { 415 + let state = state.clone(); 416 + let claims = claims.clone(); 417 + let session = session.clone(); 418 + let save_all_fn = 419 + lua.create_async_function(move |lua, records_table: mlua::Table| { 420 + let state = state.clone(); 421 + let claims = claims.clone(); 422 + let session = session.clone(); 423 + async move { 424 + // Extract save data from each record (sync) 425 + type SaveItem = (mlua::Table, String, Option<String>, Option<String>, Value); 426 + let mut save_items: Vec<SaveItem> = Vec::new(); 427 + 428 + for pair in records_table.sequence_values::<mlua::Table>() { 429 + let record_table = pair?; 430 + let collection: String = record_table.raw_get("_collection")?; 431 + let existing_uri: Option<String> = record_table.raw_get("_uri")?; 432 + let rkey: Option<String> = record_table.raw_get("_rkey")?; 433 + 434 + // Validate 435 + let schema: mlua::Value = record_table.raw_get("_schema")?; 436 + if let mlua::Value::Table(ref schema_table) = schema { 437 + validate_required_fields(&record_table, schema_table)?; 438 + } 439 + 440 + let data = extract_record_data(&lua, &record_table, &collection)?; 441 + save_items.push((record_table, collection, existing_uri, rkey, data)); 442 + } 443 + 444 + // Parallel PDS calls 445 + let futs = save_items.iter().map(|(_, collection, existing_uri, rkey, data)| { 446 + let state = state.clone(); 447 + let claims = claims.clone(); 448 + let session = session.clone(); 449 + let collection = collection.clone(); 450 + let existing_uri = existing_uri.clone(); 451 + let rkey = rkey.clone(); 452 + let data = data.clone(); 453 + async move { 454 + if let Some(ref uri) = existing_uri { 455 + let rkey = uri 456 + .split('/') 457 + .next_back() 458 + .ok_or_else(|| mlua::Error::runtime("invalid AT URI"))? 459 + .to_string(); 460 + 461 + let pds_body = json!({ 462 + "repo": claims.did(), 463 + "collection": collection, 464 + "rkey": rkey, 465 + "record": data, 466 + }); 467 + 468 + let resp = repo::pds_post_json_raw( 469 + &state, 470 + &session, 471 + "com.atproto.repo.putRecord", 472 + &pds_body, 473 + ) 474 + .await 475 + .map_err(|e| { 476 + mlua::Error::runtime(format!("PDS putRecord failed: {e}")) 477 + })?; 478 + 479 + if !resp.status().is_success() { 480 + let status = resp.status(); 481 + let body = resp.text().await.unwrap_or_default(); 482 + return Err(mlua::Error::runtime(format!( 483 + "PDS putRecord returned {status}: {body}" 484 + ))); 485 + } 486 + 487 + let bytes = resp.bytes().await.map_err(|e| { 488 + mlua::Error::runtime(format!( 489 + "failed to read PDS response: {e}" 490 + )) 491 + })?; 492 + let result: Value = serde_json::from_slice(&bytes).map_err( 493 + |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")), 494 + )?; 495 + 496 + let cid = result 497 + .get("cid") 498 + .and_then(|v| v.as_str()) 499 + .unwrap_or_default(); 500 + let _ = sqlx::query( 501 + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) 502 + VALUES ($1, $2, $3, $4, $5, $6) 503 + ON CONFLICT (uri) DO UPDATE 504 + SET record = EXCLUDED.record, 505 + cid = EXCLUDED.cid, 506 + indexed_at = NOW()"#, 507 + ) 508 + .bind(uri.as_str()) 509 + .bind(claims.did()) 510 + .bind(&collection) 511 + .bind(&rkey) 512 + .bind(&data) 513 + .bind(cid) 514 + .execute(&state.db) 515 + .await; 516 + 517 + Ok(result) 518 + } else { 519 + let mut pds_body = json!({ 520 + "repo": claims.did(), 521 + "collection": collection, 522 + "record": data, 523 + }); 524 + if let Some(ref rkey) = rkey { 525 + pds_body["rkey"] = json!(rkey); 526 + } 527 + 528 + let resp = repo::pds_post_json_raw( 529 + &state, 530 + &session, 531 + "com.atproto.repo.createRecord", 532 + &pds_body, 533 + ) 534 + .await 535 + .map_err(|e| { 536 + mlua::Error::runtime(format!( 537 + "PDS createRecord failed: {e}" 538 + )) 539 + })?; 540 + 541 + if !resp.status().is_success() { 542 + let status = resp.status(); 543 + let body = resp.text().await.unwrap_or_default(); 544 + return Err(mlua::Error::runtime(format!( 545 + "PDS createRecord returned {status}: {body}" 546 + ))); 547 + } 548 + 549 + let bytes = resp.bytes().await.map_err(|e| { 550 + mlua::Error::runtime(format!( 551 + "failed to read PDS response: {e}" 552 + )) 553 + })?; 554 + let result: Value = serde_json::from_slice(&bytes).map_err( 555 + |e| mlua::Error::runtime(format!("invalid PDS JSON: {e}")), 556 + )?; 557 + 558 + if let (Some(uri), Some(cid)) = ( 559 + result.get("uri").and_then(|v| v.as_str()), 560 + result.get("cid").and_then(|v| v.as_str()), 561 + ) { 562 + let rkey = 563 + uri.split('/').next_back().unwrap_or_default(); 564 + let _ = sqlx::query( 565 + r#"INSERT INTO records (uri, did, collection, rkey, record, cid) 566 + VALUES ($1, $2, $3, $4, $5, $6) 567 + ON CONFLICT (uri) DO UPDATE 568 + SET record = EXCLUDED.record, 569 + cid = EXCLUDED.cid"#, 570 + ) 571 + .bind(uri) 572 + .bind(claims.did()) 573 + .bind(&collection) 574 + .bind(rkey) 575 + .bind(&data) 576 + .bind(cid) 577 + .execute(&state.db) 578 + .await; 579 + } 580 + 581 + Ok(result) 582 + } 583 + } 584 + }); 585 + 586 + let results = try_join_all(futs).await?; 587 + 588 + // Write back _uri and _cid (sync) 589 + for (i, (record_table, _, _, _, _)) in save_items.iter().enumerate() { 590 + if let Some(result) = results.get(i) { 591 + if let Some(uri) = result.get("uri").and_then(|v| v.as_str()) { 592 + record_table.raw_set("_uri", uri.to_string())?; 593 + } 594 + if let Some(cid) = result.get("cid").and_then(|v| v.as_str()) { 595 + record_table.raw_set("_cid", cid.to_string())?; 596 + } 597 + } 598 + } 599 + 600 + lua.to_value(&results) 601 + } 602 + })?; 603 + record_table.set("save_all", save_all_fn)?; 604 + } 605 + 606 + // Record.load(uri) 607 + { 608 + let state = state.clone(); 609 + let metatable_c = metatable.clone(); 610 + let load_fn = lua.create_async_function(move |lua, uri: String| { 611 + let state = state.clone(); 612 + let metatable = metatable_c.clone(); 613 + async move { 614 + let row: Option<(String, Value, String)> = 615 + sqlx::query_as("SELECT collection, record, cid FROM records WHERE uri = $1") 616 + .bind(&uri) 617 + .fetch_optional(&state.db) 618 + .await 619 + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; 620 + 621 + match row { 622 + Some((collection, record, cid)) => { 623 + let table = lua.create_table()?; 624 + 625 + // Look up schema 626 + let lexicon = state.lexicons.get(&collection).await; 627 + let schema_value: mlua::Value = 628 + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { 629 + Some(schema_json) => lua.to_value(schema_json)?, 630 + None => mlua::Value::Nil, 631 + }; 632 + 633 + table.raw_set("_collection", collection.as_str())?; 634 + table.raw_set("_uri", uri.as_str())?; 635 + table.raw_set("_cid", cid.as_str())?; 636 + table.raw_set("_schema", schema_value)?; 637 + table.raw_set("_key_type", mlua::Value::Nil)?; 638 + table.raw_set("_rkey", mlua::Value::Nil)?; 639 + 640 + // Copy record fields 641 + if let Some(obj) = record.as_object() { 642 + for (k, v) in obj { 643 + if k == "$type" { 644 + continue; 645 + } 646 + let lua_val: mlua::Value = lua.to_value(v)?; 647 + table.raw_set(k.as_str(), lua_val)?; 648 + } 649 + } 650 + 651 + table.set_metatable(Some(metatable))?; 652 + Ok(mlua::Value::Table(table)) 653 + } 654 + None => Ok(mlua::Value::Nil), 655 + } 656 + } 657 + })?; 658 + record_table.set("load", load_fn)?; 659 + } 660 + 661 + // Record.load_all(uris) 662 + { 663 + let state = state; 664 + let metatable_c = metatable; 665 + let load_all_fn = lua.create_async_function(move |lua, uris_table: mlua::Table| { 666 + let state = state.clone(); 667 + let metatable = metatable_c.clone(); 668 + async move { 669 + let uris: Vec<String> = lua.from_value(mlua::Value::Table(uris_table))?; 670 + 671 + let futs = uris.iter().map(|uri| { 672 + let state = state.clone(); 673 + let uri = uri.clone(); 674 + async move { 675 + let row: Option<(String, Value, String)> = sqlx::query_as( 676 + "SELECT collection, record, cid FROM records WHERE uri = $1", 677 + ) 678 + .bind(&uri) 679 + .fetch_optional(&state.db) 680 + .await 681 + .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))?; 682 + 683 + let result: Result<_, mlua::Error> = 684 + Ok(row.map(|(collection, record, cid)| (uri, collection, record, cid))); 685 + result 686 + } 687 + }); 688 + 689 + let results: Vec<Option<(String, String, Value, String)>> = 690 + try_join_all(futs).await?; 691 + 692 + let out = lua.create_table()?; 693 + for (i, item) in results.into_iter().enumerate() { 694 + match item { 695 + Some((uri, collection, record, cid)) => { 696 + let table = lua.create_table()?; 697 + 698 + let lexicon = state.lexicons.get(&collection).await; 699 + let schema_value: mlua::Value = 700 + match lexicon.as_ref().and_then(|l| l.record_schema.as_ref()) { 701 + Some(schema_json) => lua.to_value(schema_json)?, 702 + None => mlua::Value::Nil, 703 + }; 704 + 705 + table.raw_set("_collection", collection.as_str())?; 706 + table.raw_set("_uri", uri.as_str())?; 707 + table.raw_set("_cid", cid.as_str())?; 708 + table.raw_set("_schema", schema_value)?; 709 + table.raw_set("_key_type", mlua::Value::Nil)?; 710 + table.raw_set("_rkey", mlua::Value::Nil)?; 711 + 712 + if let Some(obj) = record.as_object() { 713 + for (k, v) in obj { 714 + if k == "$type" { 715 + continue; 716 + } 717 + let lua_val: mlua::Value = lua.to_value(v)?; 718 + table.raw_set(k.as_str(), lua_val)?; 719 + } 720 + } 721 + 722 + table.set_metatable(Some(metatable.clone()))?; 723 + out.raw_set(i + 1, table)?; 724 + } 725 + None => { 726 + out.raw_set(i + 1, mlua::Value::Nil)?; 727 + } 728 + } 729 + } 730 + 731 + Ok(mlua::Value::Table(out)) 732 + } 733 + })?; 734 + record_table.set("load_all", load_all_fn)?; 735 + } 736 + 737 + // -- Make Record callable via __call metamethod -- 738 + let record_mt = lua.create_table()?; 739 + { 740 + let new_fn: mlua::Function = record_table.get("new")?; 741 + let call_fn = 742 + lua.create_async_function( 743 + move |_lua, 744 + (_self_table, collection, data): ( 745 + mlua::Table, 746 + String, 747 + Option<mlua::Value>, 748 + )| { 749 + let new_fn = new_fn.clone(); 750 + async move { 751 + let result: mlua::Table = new_fn.call_async((collection, data)).await?; 752 + Ok(result) 753 + } 754 + }, 755 + )?; 756 + record_mt.set("__call", call_fn)?; 757 + } 758 + record_table.set_metatable(Some(record_mt))?; 759 + 760 + lua.globals().set("Record", record_table)?; 761 + Ok(()) 762 + } 763 + 764 + /// Check that all required fields (per schema) are present and non-nil. 765 + fn validate_required_fields(table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> { 766 + let required: Option<mlua::Table> = schema.raw_get("required")?; 767 + if let Some(required) = required { 768 + for pair in required.sequence_values::<String>() { 769 + let field = pair?; 770 + let val: mlua::Value = table.raw_get(field.as_str())?; 771 + if val.is_nil() { 772 + return Err(mlua::Error::runtime(format!( 773 + "missing required field '{field}'" 774 + ))); 775 + } 776 + } 777 + } 778 + Ok(()) 779 + } 780 + 781 + /// Set missing fields from schema property defaults. 782 + fn populate_defaults(lua: &Lua, table: &mlua::Table, schema: &mlua::Table) -> LuaResult<()> { 783 + let properties: Option<mlua::Table> = schema.raw_get("properties")?; 784 + if let Some(properties) = properties { 785 + for pair in properties.pairs::<String, mlua::Table>() { 786 + let (key, prop_def) = pair?; 787 + // Skip internal fields 788 + if key.starts_with('_') { 789 + continue; 790 + } 791 + let existing: mlua::Value = table.raw_get(key.as_str())?; 792 + if existing.is_nil() { 793 + let default: mlua::Value = prop_def.raw_get("default")?; 794 + if !default.is_nil() { 795 + table.raw_set(key.as_str(), lua.to_value(&default)?)?; 796 + } 797 + } 798 + } 799 + } 800 + Ok(()) 801 + } 802 + 803 + /// Serialize a Record table to serde_json::Value, stripping _-prefixed keys, 804 + /// filtering to only schema-defined properties, and injecting $type. 805 + fn extract_record_data(lua: &Lua, table: &mlua::Table, collection: &str) -> LuaResult<Value> { 806 + // Build the set of allowed property names from the schema (if available). 807 + // When a schema is present, only fields listed in `properties` are included. 808 + let schema: mlua::Value = table.raw_get("_schema")?; 809 + let allowed: Option<Vec<String>> = if let mlua::Value::Table(ref schema_table) = schema { 810 + let properties: Option<mlua::Table> = schema_table.raw_get("properties")?; 811 + properties.map(|props| { 812 + props 813 + .pairs::<String, mlua::Value>() 814 + .filter_map(|pair| pair.ok().map(|(k, _)| k)) 815 + .collect() 816 + }) 817 + } else { 818 + None 819 + }; 820 + 821 + let tmp = lua.create_table()?; 822 + for pair in table.pairs::<String, mlua::Value>() { 823 + let (k, v) = pair?; 824 + if k.starts_with('_') { 825 + continue; 826 + } 827 + if let Some(ref keys) = allowed 828 + && !keys.iter().any(|a| a == &k) 829 + { 830 + continue; 831 + } 832 + tmp.raw_set(k, v)?; 833 + } 834 + 835 + let mut data: Value = lua.from_value(mlua::Value::Table(tmp))?; 836 + if let Some(obj) = data.as_object_mut() { 837 + obj.insert("$type".to_string(), json!(collection)); 838 + } 839 + Ok(data) 840 + }
+146
src/lua/sandbox.rs
··· 1 + use mlua::{Lua, Result as LuaResult}; 2 + 3 + use super::tid::generate_tid; 4 + 5 + const INSTRUCTION_LIMIT: u32 = 1_000_000; 6 + 7 + /// Create a fresh sandboxed Lua VM. 8 + /// 9 + /// - Dangerous globals (`os`, `io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`) are removed. 10 + /// - An instruction-count hook prevents infinite loops. 11 + /// - Utility globals `now()` and `log()` are injected. 12 + pub fn create_sandbox() -> LuaResult<Lua> { 13 + let lua = Lua::new(); 14 + 15 + // Remove dangerous globals 16 + let globals = lua.globals(); 17 + for name in &[ 18 + "os", 19 + "io", 20 + "debug", 21 + "package", 22 + "require", 23 + "dofile", 24 + "loadfile", 25 + "load", 26 + "collectgarbage", 27 + ] { 28 + globals.raw_set(*name, mlua::Value::Nil)?; 29 + } 30 + 31 + // Instruction limit to prevent infinite loops 32 + lua.set_hook( 33 + mlua::HookTriggers::new().every_nth_instruction(INSTRUCTION_LIMIT), 34 + |_lua, _debug| Err(mlua::Error::runtime("script exceeded execution limit")), 35 + )?; 36 + 37 + // Utility: now() returns UTC ISO 8601 string 38 + let now_fn = lua.create_function(|_, ()| Ok(chrono::Utc::now().to_rfc3339()))?; 39 + globals.set("now", now_fn)?; 40 + 41 + // Utility: log(message) logs via tracing::debug 42 + let log_fn = lua.create_function(|_, msg: String| { 43 + tracing::debug!(lua_log = %msg, "lua script log"); 44 + Ok(()) 45 + })?; 46 + globals.set("log", log_fn)?; 47 + 48 + // Utility: TID() returns a fresh AT Protocol TID string 49 + let tid_fn = lua.create_function(|_, ()| Ok(generate_tid()))?; 50 + globals.set("TID", tid_fn)?; 51 + 52 + Ok(lua) 53 + } 54 + 55 + /// Validate that a script compiles and defines a `handle` function. 56 + pub fn validate_script(source: &str) -> Result<(), String> { 57 + let lua = create_sandbox().map_err(|e| format!("failed to create Lua VM: {e}"))?; 58 + lua.load(source) 59 + .exec() 60 + .map_err(|e| format!("script compilation failed: {e}"))?; 61 + 62 + let globals = lua.globals(); 63 + match globals.get::<mlua::Function>("handle") { 64 + Ok(_) => Ok(()), 65 + Err(_) => Err("script must define a handle() function".into()), 66 + } 67 + } 68 + 69 + #[cfg(test)] 70 + mod tests { 71 + use super::*; 72 + 73 + #[test] 74 + fn sandbox_removes_dangerous_globals() { 75 + let lua = create_sandbox().unwrap(); 76 + let globals = lua.globals(); 77 + assert!(globals.get::<mlua::Value>("os").unwrap().is_nil()); 78 + assert!(globals.get::<mlua::Value>("io").unwrap().is_nil()); 79 + assert!(globals.get::<mlua::Value>("debug").unwrap().is_nil()); 80 + assert!(globals.get::<mlua::Value>("package").unwrap().is_nil()); 81 + assert!(globals.get::<mlua::Value>("require").unwrap().is_nil()); 82 + } 83 + 84 + #[test] 85 + fn sandbox_provides_now() { 86 + let lua = create_sandbox().unwrap(); 87 + let result: String = lua.load("return now()").eval().unwrap(); 88 + assert!(result.contains("T")); // ISO 8601 format 89 + } 90 + 91 + #[test] 92 + fn sandbox_provides_log() { 93 + let lua = create_sandbox().unwrap(); 94 + lua.load(r#"log("test message")"#).exec().unwrap(); 95 + } 96 + 97 + #[test] 98 + fn sandbox_provides_tid() { 99 + let lua = create_sandbox().unwrap(); 100 + let result: String = lua.load("return TID()").eval().unwrap(); 101 + assert_eq!(result.len(), 13); 102 + let valid = "234567abcdefghijklmnopqrstuvwxyz"; 103 + for ch in result.chars() { 104 + assert!(valid.contains(ch), "invalid char '{ch}' in TID"); 105 + } 106 + } 107 + 108 + #[test] 109 + fn sandbox_tid_returns_unique_values() { 110 + let lua = create_sandbox().unwrap(); 111 + let a: String = lua.load("return TID()").eval().unwrap(); 112 + let b: String = lua.load("return TID()").eval().unwrap(); 113 + assert_ne!(a, b); 114 + } 115 + 116 + #[test] 117 + fn sandbox_kills_infinite_loop() { 118 + let lua = create_sandbox().unwrap(); 119 + let result = lua.load("while true do end").exec(); 120 + assert!(result.is_err()); 121 + let err = result.unwrap_err().to_string(); 122 + assert!( 123 + err.contains("execution limit"), 124 + "expected execution limit error, got: {err}" 125 + ); 126 + } 127 + 128 + #[test] 129 + fn validate_script_accepts_valid() { 130 + let result = validate_script("function handle() return {} end"); 131 + assert!(result.is_ok()); 132 + } 133 + 134 + #[test] 135 + fn validate_script_rejects_missing_handle() { 136 + let result = validate_script("function other() return {} end"); 137 + assert!(result.is_err()); 138 + assert!(result.unwrap_err().contains("handle")); 139 + } 140 + 141 + #[test] 142 + fn validate_script_rejects_syntax_error() { 143 + let result = validate_script("function handle("); 144 + assert!(result.is_err()); 145 + } 146 + }
+76
src/lua/tid.rs
··· 1 + use std::time::{SystemTime, UNIX_EPOCH}; 2 + 3 + /// Base32-sortstring alphabet used by AT Protocol TIDs. 4 + const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; 5 + 6 + /// Generate a TID (timestamp identifier) compatible with the AT Protocol spec. 7 + /// 8 + /// Layout: 64-bit value = `(microsecond_timestamp << 10) | random_10bit_clock_id` 9 + /// Encoded as a 13-character base32-sortstring. 10 + pub fn generate_tid() -> String { 11 + let us = SystemTime::now() 12 + .duration_since(UNIX_EPOCH) 13 + .expect("system clock before UNIX epoch") 14 + .as_micros() as u64; 15 + 16 + // 10-bit random clock ID from UUID v4 bytes 17 + let rand_bytes = uuid::Uuid::new_v4(); 18 + let clock_id = u16::from_le_bytes([rand_bytes.as_bytes()[0], rand_bytes.as_bytes()[1]]) & 0x3FF; 19 + 20 + let val = (us << 10) | clock_id as u64; 21 + encode_base32_sort(val) 22 + } 23 + 24 + /// Encode a u64 into a 13-character base32-sortstring. 25 + fn encode_base32_sort(mut val: u64) -> String { 26 + let mut buf = [0u8; 13]; 27 + for i in (0..13).rev() { 28 + buf[i] = BASE32_SORT[(val & 0x1F) as usize]; 29 + val >>= 5; 30 + } 31 + // SAFETY: all bytes come from BASE32_SORT which is ASCII 32 + String::from_utf8(buf.to_vec()).unwrap() 33 + } 34 + 35 + #[cfg(test)] 36 + mod tests { 37 + use super::*; 38 + 39 + #[test] 40 + fn tid_is_13_chars() { 41 + let tid = generate_tid(); 42 + assert_eq!(tid.len(), 13, "TID should be 13 characters, got: {tid}"); 43 + } 44 + 45 + #[test] 46 + fn tid_uses_valid_charset() { 47 + let tid = generate_tid(); 48 + let valid = "234567abcdefghijklmnopqrstuvwxyz"; 49 + for ch in tid.chars() { 50 + assert!(valid.contains(ch), "invalid character '{ch}' in TID {tid}"); 51 + } 52 + } 53 + 54 + #[test] 55 + fn tids_are_unique() { 56 + let a = generate_tid(); 57 + let b = generate_tid(); 58 + assert_ne!(a, b, "two TIDs should differ"); 59 + } 60 + 61 + #[test] 62 + fn tids_are_sortable() { 63 + // TIDs generated later should sort after earlier ones 64 + let a = generate_tid(); 65 + std::thread::sleep(std::time::Duration::from_millis(2)); 66 + let b = generate_tid(); 67 + assert!(b > a, "later TID '{b}' should sort after earlier TID '{a}'"); 68 + } 69 + 70 + #[test] 71 + fn encode_base32_sort_known_value() { 72 + // Zero should encode to all '2's (the first character in the alphabet) 73 + let result = encode_base32_sort(0); 74 + assert_eq!(result, "2222222222222"); 75 + } 76 + }
+1
src/main.rs
··· 55 55 1, 56 56 target_collection.clone(), 57 57 ProcedureAction::Upsert, 58 + None, 58 59 ) { 59 60 Ok(parsed) => { 60 61 if let Err(e) = sqlx::query(
-44
src/repo/at_uri.rs
··· 1 - use crate::error::AppError; 2 - 3 - /// Extract the DID from an AT URI (at://did/collection/rkey). 4 - #[allow(dead_code)] 5 - pub(crate) fn parse_did_from_at_uri(uri: &str) -> Result<String, AppError> { 6 - let stripped = uri 7 - .strip_prefix("at://") 8 - .ok_or_else(|| AppError::Internal("AT URI must start with at://".into()))?; 9 - 10 - stripped 11 - .split('/') 12 - .next() 13 - .map(|s| s.to_string()) 14 - .ok_or_else(|| AppError::Internal("invalid AT URI".into())) 15 - } 16 - 17 - #[cfg(test)] 18 - mod tests { 19 - use super::*; 20 - 21 - #[test] 22 - fn parse_did_from_valid_at_uri() { 23 - let did = parse_did_from_at_uri("at://did:plc:abc123/app.bsky.feed.post/3k2bqxyz").unwrap(); 24 - assert_eq!(did, "did:plc:abc123"); 25 - } 26 - 27 - #[test] 28 - fn parse_did_from_uri_with_no_rkey() { 29 - let did = parse_did_from_at_uri("at://did:plc:abc123/collection").unwrap(); 30 - assert_eq!(did, "did:plc:abc123"); 31 - } 32 - 33 - #[test] 34 - fn parse_did_from_did_web_uri() { 35 - let did = parse_did_from_at_uri("at://did:web:example.com/collection/rkey").unwrap(); 36 - assert_eq!(did, "did:web:example.com"); 37 - } 38 - 39 - #[test] 40 - fn parse_did_from_uri_missing_prefix() { 41 - let result = parse_did_from_at_uri("did:plc:abc123/collection/rkey"); 42 - assert!(result.is_err()); 43 - } 44 - }
-111
src/repo/media.rs
··· 1 - use serde_json::{Value, json}; 2 - 3 - /// Walk `media[]` and add a `url` field to each blob so the frontend can 4 - /// display images directly. 5 - #[allow(dead_code)] 6 - pub(crate) fn enrich_media_blobs(record: &mut Value, pds: &str, did: &str) { 7 - let media = match record.get_mut("media").and_then(|m| m.as_array_mut()) { 8 - Some(arr) => arr, 9 - None => return, 10 - }; 11 - 12 - let pds_base = pds.trim_end_matches('/'); 13 - 14 - for item in media.iter_mut() { 15 - let cid = item 16 - .get("blob") 17 - .and_then(|b| b.get("ref")) 18 - .and_then(|r| r.get("$link")) 19 - .and_then(|l| l.as_str()) 20 - .map(|s| s.to_string()); 21 - 22 - if let Some(cid) = cid 23 - && let Some(blob) = item.get_mut("blob") 24 - && let Some(obj) = blob.as_object_mut() 25 - { 26 - obj.insert( 27 - "url".to_string(), 28 - json!(format!( 29 - "{pds_base}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" 30 - )), 31 - ); 32 - } 33 - } 34 - } 35 - 36 - #[cfg(test)] 37 - mod tests { 38 - use super::*; 39 - 40 - #[test] 41 - fn enrich_media_adds_url() { 42 - let mut record = json!({ 43 - "media": [{ 44 - "blob": { 45 - "ref": { "$link": "bafyreiabc" }, 46 - "mimeType": "image/jpeg", 47 - "size": 1024 48 - } 49 - }] 50 - }); 51 - 52 - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); 53 - 54 - let url = record["media"][0]["blob"]["url"].as_str().unwrap(); 55 - assert_eq!( 56 - url, 57 - "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreiabc" 58 - ); 59 - } 60 - 61 - #[test] 62 - fn enrich_media_noop_without_media() { 63 - let mut record = json!({"title": "test"}); 64 - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); 65 - assert!(record.get("media").is_none()); 66 - } 67 - 68 - #[test] 69 - fn enrich_media_skips_items_without_ref() { 70 - let mut record = json!({ 71 - "media": [{ 72 - "blob": { "mimeType": "image/png" } 73 - }] 74 - }); 75 - 76 - enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test"); 77 - assert!(record["media"][0]["blob"].get("url").is_none()); 78 - } 79 - 80 - #[test] 81 - fn enrich_media_handles_multiple_items() { 82 - let mut record = json!({ 83 - "media": [ 84 - { "blob": { "ref": { "$link": "cid1" } } }, 85 - { "blob": { "ref": { "$link": "cid2" } } } 86 - ] 87 - }); 88 - 89 - enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:x"); 90 - 91 - let url1 = record["media"][0]["blob"]["url"].as_str().unwrap(); 92 - let url2 = record["media"][1]["blob"]["url"].as_str().unwrap(); 93 - assert!(url1.contains("cid1")); 94 - assert!(url2.contains("cid2")); 95 - } 96 - 97 - #[test] 98 - fn enrich_media_trims_trailing_slash() { 99 - let mut record = json!({ 100 - "media": [{ 101 - "blob": { "ref": { "$link": "bafytest" } } 102 - }] 103 - }); 104 - 105 - enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:test"); 106 - 107 - let url = record["media"][0]["blob"]["url"].as_str().unwrap(); 108 - assert!(url.starts_with("https://pds.example.com/xrpc/")); 109 - assert!(!url.contains("//xrpc")); 110 - } 111 - }
-4
src/repo/mod.rs
··· 1 - mod at_uri; 2 1 mod dpop; 3 - mod media; 4 2 mod pds; 5 3 pub(crate) mod session; 6 4 mod upload_blob; 7 5 8 - pub(crate) use at_uri::parse_did_from_at_uri; 9 - pub(crate) use media::enrich_media_blobs; 10 6 pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; 11 7 pub(crate) use session::{AtpSession, get_atp_session}; 12 8 pub use upload_blob::upload_blob;
+1
src/tap.rs
··· 409 409 1, 410 410 target_collection.clone(), 411 411 ProcedureAction::Upsert, 412 + None, 412 413 ) { 413 414 Ok(p) => p, 414 415 Err(e) => {
+5
src/xrpc/procedure.rs
··· 15 15 input: &Value, 16 16 lexicon: &crate::lexicon::ParsedLexicon, 17 17 ) -> Result<Response, AppError> { 18 + if let Some(ref script) = lexicon.script { 19 + return crate::lua::execute_procedure_script(state, method, claims, input, lexicon, script) 20 + .await; 21 + } 22 + 18 23 let collection = lexicon.target_collection.as_deref().ok_or_else(|| { 19 24 AppError::BadRequest(format!("{method} has no target_collection configured")) 20 25 })?;
+6 -23
src/xrpc/query.rs
··· 1 1 use axum::Json; 2 2 use axum::response::{IntoResponse, Response}; 3 3 use serde_json::{Value, json}; 4 - use std::collections::{HashMap, HashSet}; 4 + use std::collections::HashMap; 5 5 6 6 use crate::AppState; 7 7 use crate::error::AppError; 8 - use crate::profile; 9 - use crate::repo; 10 8 11 9 pub(super) async fn handle_query( 12 10 state: &AppState, ··· 14 12 params: &HashMap<String, String>, 15 13 lexicon: &crate::lexicon::ParsedLexicon, 16 14 ) -> Result<Response, AppError> { 15 + if let Some(ref script) = lexicon.script { 16 + return crate::lua::execute_query_script(state, method, params, lexicon, script).await; 17 + } 18 + 17 19 // Single-record query: has a `uri` parameter 18 20 if let Some(uri) = params.get("uri") { 19 21 return handle_get_record(state, uri).await; ··· 64 66 65 67 let has_next_page = rows.len() as i64 == limit; 66 68 67 - // Resolve PDS endpoints for blob URL enrichment. 68 - let unique_dids: HashSet<&str> = rows.iter().map(|(_, did, _)| did.as_str()).collect(); 69 - let mut pds_map: HashMap<String, String> = HashMap::new(); 70 - for did in unique_dids { 71 - if let Ok(pds) = 72 - profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, did).await 73 - { 74 - pds_map.insert(did.to_string(), pds); 75 - } 76 - } 77 - 78 69 let records: Vec<Value> = rows 79 70 .into_iter() 80 - .map(|(uri, did, mut record)| { 81 - if let Some(pds) = pds_map.get(&did) { 82 - repo::enrich_media_blobs(&mut record, pds, &did); 83 - } 71 + .map(|(uri, _did, mut record)| { 84 72 record 85 73 .as_object_mut() 86 74 .map(|obj| obj.insert("uri".to_string(), json!(uri))); ··· 101 89 } 102 90 103 91 pub(super) async fn handle_get_record(state: &AppState, uri: &str) -> Result<Response, AppError> { 104 - let did = repo::parse_did_from_at_uri(uri)?; 105 - 106 92 let row: Option<(Value,)> = sqlx::query_as("SELECT record FROM records WHERE uri = $1") 107 93 .bind(uri) 108 94 .fetch_optional(&state.db) ··· 110 96 .map_err(|e| AppError::Internal(format!("DB query failed: {e}")))?; 111 97 112 98 let (mut record,) = row.ok_or_else(|| AppError::NotFound("record not found".into()))?; 113 - 114 - let pds = profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &did).await?; 115 - repo::enrich_media_blobs(&mut record, &pds, &did); 116 99 117 100 record 118 101 .as_object_mut()
+72
web/package-lock.json
··· 12 12 "@dnd-kit/modifiers": "^9.0.0", 13 13 "@dnd-kit/sortable": "^10.0.0", 14 14 "@dnd-kit/utilities": "^3.2.2", 15 + "@monaco-editor/react": "^4.7.0", 15 16 "@tabler/icons-react": "^3.36.1", 16 17 "@tanstack/react-table": "^8.21.3", 17 18 "class-variance-authority": "^0.7.1", ··· 1769 1770 "dev": true, 1770 1771 "license": "MIT" 1771 1772 }, 1773 + "node_modules/@monaco-editor/loader": { 1774 + "version": "1.7.0", 1775 + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", 1776 + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", 1777 + "license": "MIT", 1778 + "dependencies": { 1779 + "state-local": "^1.0.6" 1780 + } 1781 + }, 1782 + "node_modules/@monaco-editor/react": { 1783 + "version": "4.7.0", 1784 + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", 1785 + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", 1786 + "license": "MIT", 1787 + "dependencies": { 1788 + "@monaco-editor/loader": "^1.5.0" 1789 + }, 1790 + "peerDependencies": { 1791 + "monaco-editor": ">= 0.25.0 < 1", 1792 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 1793 + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 1794 + } 1795 + }, 1772 1796 "node_modules/@mswjs/interceptors": { 1773 1797 "version": "0.41.3", 1774 1798 "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", ··· 4253 4277 "dev": true, 4254 4278 "license": "MIT" 4255 4279 }, 4280 + "node_modules/@types/trusted-types": { 4281 + "version": "2.0.7", 4282 + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", 4283 + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 4284 + "license": "MIT", 4285 + "optional": true, 4286 + "peer": true 4287 + }, 4256 4288 "node_modules/@types/unist": { 4257 4289 "version": "3.0.3", 4258 4290 "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", ··· 6272 6304 "csstype": "^3.0.2" 6273 6305 } 6274 6306 }, 6307 + "node_modules/dompurify": { 6308 + "version": "3.2.7", 6309 + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", 6310 + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", 6311 + "license": "(MPL-2.0 OR Apache-2.0)", 6312 + "peer": true, 6313 + "optionalDependencies": { 6314 + "@types/trusted-types": "^2.0.7" 6315 + } 6316 + }, 6275 6317 "node_modules/dotenv": { 6276 6318 "version": "17.3.1", 6277 6319 "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", ··· 9335 9377 "@jridgewell/sourcemap-codec": "^1.5.5" 9336 9378 } 9337 9379 }, 9380 + "node_modules/marked": { 9381 + "version": "14.0.0", 9382 + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", 9383 + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", 9384 + "license": "MIT", 9385 + "peer": true, 9386 + "bin": { 9387 + "marked": "bin/marked.js" 9388 + }, 9389 + "engines": { 9390 + "node": ">= 18" 9391 + } 9392 + }, 9338 9393 "node_modules/math-intrinsics": { 9339 9394 "version": "1.1.0", 9340 9395 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ··· 10065 10120 "license": "MIT", 10066 10121 "funding": { 10067 10122 "url": "https://github.com/sponsors/ljharb" 10123 + } 10124 + }, 10125 + "node_modules/monaco-editor": { 10126 + "version": "0.55.1", 10127 + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", 10128 + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", 10129 + "license": "MIT", 10130 + "peer": true, 10131 + "dependencies": { 10132 + "dompurify": "3.2.7", 10133 + "marked": "14.0.0" 10068 10134 } 10069 10135 }, 10070 10136 "node_modules/ms": { ··· 12129 12195 "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", 12130 12196 "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", 12131 12197 "dev": true, 12198 + "license": "MIT" 12199 + }, 12200 + "node_modules/state-local": { 12201 + "version": "1.0.7", 12202 + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", 12203 + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", 12132 12204 "license": "MIT" 12133 12205 }, 12134 12206 "node_modules/statuses": {
+1
web/package.json
··· 13 13 "@dnd-kit/modifiers": "^9.0.0", 14 14 "@dnd-kit/sortable": "^10.0.0", 15 15 "@dnd-kit/utilities": "^3.2.2", 16 + "@monaco-editor/react": "^4.7.0", 16 17 "@tabler/icons-react": "^3.36.1", 17 18 "@tanstack/react-table": "^8.21.3", 18 19 "class-variance-authority": "^0.7.1",
+226
web/src/app/(dashboard)/lexicons/[id]/page.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback, useEffect, useState } from "react"; 4 + import { useParams, useRouter } from "next/navigation"; 5 + 6 + import { useAuth } from "@/lib/auth-context"; 7 + import { CodePanels } from "@/components/code-panels"; 8 + import { 9 + deleteLexicon, 10 + deleteNetworkLexicon, 11 + getLexicon, 12 + uploadLexicon, 13 + type LexiconDetail, 14 + } from "@/lib/api"; 15 + import { procedureScript, queryScript } from "@/lib/lua-templates"; 16 + import { useLuaCompletions } from "@/hooks/use-lua-completions"; 17 + import { SiteHeader } from "@/components/site-header"; 18 + import { Badge } from "@/components/ui/badge"; 19 + import { Button } from "@/components/ui/button"; 20 + import { Label } from "@/components/ui/label"; 21 + 22 + export default function LexiconDetailPage() { 23 + const { id } = useParams<{ id: string }>(); 24 + const { getToken } = useAuth(); 25 + const router = useRouter(); 26 + const [lexicon, setLexicon] = useState<LexiconDetail | null>(null); 27 + const [error, setError] = useState<string | null>(null); 28 + const [deleting, setDeleting] = useState(false); 29 + const [saving, setSaving] = useState(false); 30 + 31 + // Editable text state 32 + const [jsonText, setJsonText] = useState(""); 33 + const [luaText, setLuaText] = useState(""); 34 + const [originalJson, setOriginalJson] = useState(""); 35 + const [originalLua, setOriginalLua] = useState(""); 36 + const { luaCompletions, collections } = useLuaCompletions(jsonText); 37 + 38 + const load = useCallback(() => { 39 + getLexicon(getToken, id) 40 + .then((lex) => { 41 + setLexicon(lex); 42 + const json = JSON.stringify(lex.lexicon_json, null, 2); 43 + setJsonText(json); 44 + setOriginalJson(json); 45 + 46 + // If lexicon has no script but is a query/procedure, auto-generate one 47 + if ( 48 + !lex.script && 49 + (lex.lexicon_type === "query" || lex.lexicon_type === "procedure") 50 + ) { 51 + const generated = 52 + lex.lexicon_type === "procedure" 53 + ? procedureScript(lex.target_collection ?? "") 54 + : queryScript(lex.target_collection ?? ""); 55 + setLuaText(generated); 56 + // Set originalLua to "" so isDirty becomes true, prompting user to save 57 + setOriginalLua(""); 58 + } else { 59 + setLuaText(lex.script ?? ""); 60 + setOriginalLua(lex.script ?? ""); 61 + } 62 + }) 63 + .catch((e) => setError(e instanceof Error ? e.message : String(e))); 64 + }, [getToken, id]); 65 + 66 + useEffect(() => { 67 + load(); 68 + }, [load]); 69 + 70 + const isDirty = jsonText !== originalJson || luaText !== originalLua; 71 + 72 + async function handleSave() { 73 + if (!lexicon) return; 74 + setSaving(true); 75 + setError(null); 76 + try { 77 + const lexiconJson = JSON.parse(jsonText); 78 + await uploadLexicon(getToken, { 79 + lexicon_json: lexiconJson, 80 + backfill: lexicon.backfill, 81 + script: luaText || undefined, 82 + }); 83 + load(); 84 + } catch (e: unknown) { 85 + setError(e instanceof Error ? e.message : String(e)); 86 + } finally { 87 + setSaving(false); 88 + } 89 + } 90 + 91 + async function handleDelete() { 92 + if (!lexicon) return; 93 + setDeleting(true); 94 + try { 95 + if (lexicon.source === "network") { 96 + await deleteNetworkLexicon(getToken, lexicon.id); 97 + } else { 98 + await deleteLexicon(getToken, lexicon.id); 99 + } 100 + router.push("/lexicons"); 101 + } catch (e: unknown) { 102 + setError(e instanceof Error ? e.message : String(e)); 103 + setDeleting(false); 104 + } 105 + } 106 + 107 + if (error && !lexicon) { 108 + return ( 109 + <> 110 + <SiteHeader title="Lexicon" backHref="/lexicons" /> 111 + <div className="p-4 md:p-6"> 112 + <p className="text-destructive text-sm">{error}</p> 113 + </div> 114 + </> 115 + ); 116 + } 117 + 118 + if (!lexicon) { 119 + return ( 120 + <> 121 + <SiteHeader title="Lexicon" backHref="/lexicons" /> 122 + <div className="p-4 md:p-6"> 123 + <p className="text-muted-foreground text-sm">Loading...</p> 124 + </div> 125 + </> 126 + ); 127 + } 128 + 129 + const isNetwork = lexicon.source === "network"; 130 + const showLua = 131 + lexicon.has_script || 132 + lexicon.lexicon_type === "query" || 133 + lexicon.lexicon_type === "procedure"; 134 + 135 + return ( 136 + <div className="flex flex-col h-full max-h-screen md:max-h-[calc(100vh-((var(--spacing)*2)*2))] overflow-hidden"> 137 + <SiteHeader title={lexicon.id} backHref="/lexicons" /> 138 + <div className="flex flex-col flex-1 min-h-0 gap-6 items-stretch overflow-hidden"> 139 + <div className="p-4 md:p-6"> 140 + {error && <p className="text-destructive text-sm mb-4">{error}</p>} 141 + 142 + {/* Metadata */} 143 + <div className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-3 lg:grid-cols-4"> 144 + <div> 145 + <Label className="text-muted-foreground">Type</Label> 146 + <div className="mt-1"> 147 + <Badge variant="outline">{lexicon.lexicon_type}</Badge> 148 + </div> 149 + </div> 150 + <div> 151 + <Label className="text-muted-foreground">Source</Label> 152 + <div className="mt-1"> 153 + <Badge variant={isNetwork ? "secondary" : "outline"}> 154 + {lexicon.source} 155 + </Badge> 156 + </div> 157 + </div> 158 + <div> 159 + <Label className="text-muted-foreground">Revision</Label> 160 + <p className="mt-1 tabular-nums">{lexicon.revision}</p> 161 + </div> 162 + <div> 163 + <Label className="text-muted-foreground">Backfill</Label> 164 + <p className="mt-1">{lexicon.backfill ? "Yes" : "No"}</p> 165 + </div> 166 + {lexicon.authority_did && ( 167 + <div className="col-span-2"> 168 + <Label className="text-muted-foreground">Authority DID</Label> 169 + <p className="mt-1 font-mono text-sm break-all"> 170 + {lexicon.authority_did} 171 + </p> 172 + </div> 173 + )} 174 + <div> 175 + <Label className="text-muted-foreground">Created</Label> 176 + <p className="mt-1 text-sm"> 177 + {new Date(lexicon.created_at).toLocaleString()} 178 + </p> 179 + </div> 180 + <div> 181 + <Label className="text-muted-foreground">Updated</Label> 182 + <p className="mt-1 text-sm"> 183 + {new Date(lexicon.updated_at).toLocaleString()} 184 + </p> 185 + </div> 186 + {lexicon.last_fetched_at && ( 187 + <div> 188 + <Label className="text-muted-foreground">Last Fetched</Label> 189 + <p className="mt-1 text-sm"> 190 + {new Date(lexicon.last_fetched_at).toLocaleString()} 191 + </p> 192 + </div> 193 + )} 194 + </div> 195 + </div> 196 + 197 + {/* Code Panels */} 198 + <CodePanels 199 + className="flex-1 min-h-0 px-4 md:px-6" 200 + jsonValue={jsonText} 201 + onJsonChange={isNetwork ? undefined : setJsonText} 202 + jsonReadOnly={isNetwork} 203 + luaValue={showLua ? luaText : undefined} 204 + onLuaChange={showLua ? setLuaText : undefined} 205 + luaCompletions={showLua ? luaCompletions : undefined} 206 + collections={showLua ? collections : undefined} 207 + /> 208 + 209 + {/* Actions */} 210 + <footer className="bg-sidebar-accent flex justify-between gap-2 ps-4 py-2 md:px-6 md:py-4 rounded-b-md"> 211 + <Button 212 + variant="destructive" 213 + onClick={handleDelete} 214 + disabled={deleting} 215 + > 216 + {deleting ? "Deleting..." : "Delete Lexicon"} 217 + </Button> 218 + 219 + <Button onClick={handleSave} disabled={!isDirty || saving}> 220 + {saving ? "Saving..." : "Save"} 221 + </Button> 222 + </footer> 223 + </div> 224 + </div> 225 + ); 226 + }
+293
web/src/app/(dashboard)/lexicons/new/page.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useMemo, useRef, useState } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty"; 6 + import { useAuth } from "@/lib/auth-context"; 7 + import { 8 + addNetworkLexicon, 9 + uploadLexicon, 10 + } from "@/lib/api"; 11 + import { resolveNsid } from "@/lib/nsid"; 12 + import { LEXICON_TEMPLATE, procedureScript, queryScript } from "@/lib/lua-templates"; 13 + import { useLuaCompletions } from "@/hooks/use-lua-completions"; 14 + import { CodePanels } from "@/components/code-panels"; 15 + import { SiteHeader } from "@/components/site-header"; 16 + import { Button } from "@/components/ui/button"; 17 + import { Input } from "@/components/ui/input"; 18 + import { Label } from "@/components/ui/label"; 19 + import { Switch } from "@/components/ui/switch"; 20 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 21 + 22 + export default function AddLexiconPage() { 23 + const { getToken } = useAuth(); 24 + const router = useRouter(); 25 + const [error, setError] = useState<string | null>(null); 26 + const [submitting, setSubmitting] = useState(false); 27 + 28 + // Local state 29 + const [json, setJson] = useState(LEXICON_TEMPLATE); 30 + const [localTargetCollection, setLocalTargetCollection] = useState(""); 31 + const [script, setScript] = useState(""); 32 + const [backfill, setBackfill] = useState(true); 33 + const scriptManuallyEdited = useRef(false); 34 + 35 + // Collections for Record() completions and record schemas 36 + const { luaCompletions, collections } = useLuaCompletions(json); 37 + 38 + // Network state 39 + const [nsid, setNsid] = useState(""); 40 + const [networkTargetCollection, setNetworkTargetCollection] = useState(""); 41 + const [networkJson, setNetworkJson] = useState(""); 42 + const [mainType, setMainType] = useState<string | undefined>(); 43 + const [resolving, setResolving] = useState(false); 44 + const abortRef = useRef<AbortController | null>(null); 45 + 46 + const lastValidType = useRef<string | undefined>(undefined); 47 + const localMainType = useMemo(() => { 48 + try { 49 + const parsed = JSON.parse(json); 50 + const type = parsed?.defs?.main?.type as string | undefined; 51 + lastValidType.current = type; 52 + return type; 53 + } catch { 54 + return lastValidType.current; 55 + } 56 + }, [json]); 57 + 58 + const showLocalTargetCollection = 59 + localMainType === "query" || localMainType === "procedure"; 60 + const showScript = localMainType === "query" || localMainType === "procedure"; 61 + 62 + // Auto-generate script when type or target collection changes 63 + useEffect(() => { 64 + if (scriptManuallyEdited.current) return; 65 + if (localMainType === "procedure") { 66 + setScript(procedureScript(localTargetCollection)); 67 + } else if (localMainType === "query") { 68 + setScript(queryScript(localTargetCollection)); 69 + } 70 + }, [localMainType, localTargetCollection]); 71 + 72 + function handleScriptChange(value: string) { 73 + scriptManuallyEdited.current = true; 74 + setScript(value); 75 + } 76 + 77 + // Reset manual-edit flag when type changes 78 + const prevType = useRef(localMainType); 79 + useEffect(() => { 80 + if (prevType.current !== localMainType) { 81 + scriptManuallyEdited.current = false; 82 + prevType.current = localMainType; 83 + } 84 + }, [localMainType]); 85 + 86 + // Debounced NSID resolution 87 + useEffect(() => { 88 + abortRef.current?.abort(); 89 + setMainType(undefined); 90 + setNetworkJson(""); 91 + 92 + if (nsid.split(".").length < 3) return; 93 + 94 + const debounce = setTimeout(() => { 95 + const controller = new AbortController(); 96 + abortRef.current = controller; 97 + setResolving(true); 98 + 99 + resolveNsid(nsid, controller.signal) 100 + .then((result) => { 101 + if (!controller.signal.aborted) { 102 + setMainType(result.type); 103 + setNetworkJson( 104 + result.lexiconJson 105 + ? JSON.stringify(result.lexiconJson, null, 2) 106 + : "", 107 + ); 108 + } 109 + }) 110 + .finally(() => { 111 + if (!controller.signal.aborted) setResolving(false); 112 + }); 113 + }, 500); 114 + 115 + return () => clearTimeout(debounce); 116 + }, [nsid]); 117 + 118 + const showNetworkTargetCollection = 119 + mainType === "query" || mainType === "procedure"; 120 + 121 + async function handleUploadLocal() { 122 + setError(null); 123 + setSubmitting(true); 124 + try { 125 + const lexiconJson = JSON.parse(json); 126 + await uploadLexicon(getToken, { 127 + lexicon_json: lexiconJson, 128 + backfill, 129 + script: showScript && script ? script : undefined, 130 + }); 131 + router.push("/lexicons"); 132 + } catch (e: unknown) { 133 + setError(e instanceof Error ? e.message : String(e)); 134 + setSubmitting(false); 135 + } 136 + } 137 + 138 + async function handleAddNetwork() { 139 + setError(null); 140 + setSubmitting(true); 141 + try { 142 + await addNetworkLexicon(getToken, { 143 + nsid, 144 + target_collection: showNetworkTargetCollection 145 + ? networkTargetCollection || undefined 146 + : undefined, 147 + }); 148 + router.push("/lexicons"); 149 + } catch (e: unknown) { 150 + setError(e instanceof Error ? e.message : String(e)); 151 + setSubmitting(false); 152 + } 153 + } 154 + 155 + return ( 156 + <> 157 + <SiteHeader title="Add Lexicon" backHref="/lexicons" /> 158 + 159 + <div className="flex flex-1 flex-col"> 160 + <Tabs 161 + defaultValue="local" 162 + className="flex flex-col flex-1 gap-0 min-h-0" 163 + > 164 + <div className="p-4 md:p-6"> 165 + <TabsList className="w-full max-w-md"> 166 + <TabsTrigger value="local" className="flex-1"> 167 + Local 168 + </TabsTrigger> 169 + <TabsTrigger value="network" className="flex-1"> 170 + Network 171 + </TabsTrigger> 172 + </TabsList> 173 + </div> 174 + 175 + <TabsContent value="local" className="flex flex-col flex-1 min-h-0"> 176 + <div className="flex flex-col flex-1 min-h-0 gap-6 p-4 pt-0 md:p-6 md:pt-0"> 177 + {error && <p className="text-destructive text-sm">{error}</p>} 178 + 179 + {/* Metadata fields */} 180 + {/* <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 181 + {showLocalTargetCollection && ( 182 + <div className="flex flex-col gap-2"> 183 + <Label htmlFor="target-collection"> 184 + Record Collection (optional) 185 + </Label> 186 + <Input 187 + id="target-collection" 188 + value={localTargetCollection} 189 + onChange={(e) => setLocalTargetCollection(e.target.value)} 190 + placeholder="com.example.record" 191 + /> 192 + </div> 193 + )} 194 + </div> */} 195 + 196 + {/* Code panels */} 197 + <CodePanels 198 + className="flex-1 min-h-0" 199 + jsonValue={json} 200 + onJsonChange={setJson} 201 + luaValue={showScript ? script : undefined} 202 + onLuaChange={showScript ? handleScriptChange : undefined} 203 + luaCompletions={showScript ? luaCompletions : undefined} 204 + collections={showScript ? collections : undefined} 205 + /> 206 + </div> 207 + 208 + <footer className="bg-sidebar-accent flex justify-end gap-6 ps-4 pt-2 pb-1 md:px-6 md:py-4 rounded-b-md"> 209 + <div className="flex items-center gap-2"> 210 + <Label htmlFor="backfill">Enable backfill for lexicon</Label> 211 + <Switch 212 + id="backfill" 213 + checked={backfill} 214 + onCheckedChange={setBackfill} 215 + /> 216 + </div> 217 + 218 + <Button onClick={handleUploadLocal} disabled={submitting}> 219 + {submitting ? "Uploading..." : "Upload"} 220 + </Button> 221 + </footer> 222 + </TabsContent> 223 + 224 + <TabsContent value="network" className="flex flex-col flex-1 min-h-0"> 225 + <div className="flex flex-col flex-1 min-h-0 gap-6 p-4 pt-0 md:p-6 md:pt-0"> 226 + {error && <p className="text-destructive text-sm">{error}</p>} 227 + 228 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 229 + <div className="flex flex-col gap-2"> 230 + <Label htmlFor="nsid">NSID</Label> 231 + <Input 232 + id="nsid" 233 + value={nsid} 234 + onChange={(e) => setNsid(e.target.value)} 235 + placeholder="com.example.record" 236 + /> 237 + </div> 238 + 239 + {showNetworkTargetCollection && ( 240 + <div className="flex flex-col gap-2"> 241 + <Label htmlFor="nl-target-collection"> 242 + Target Collection (optional) 243 + </Label> 244 + <Input 245 + id="nl-target-collection" 246 + value={networkTargetCollection} 247 + onChange={(e) => 248 + setNetworkTargetCollection(e.target.value) 249 + } 250 + placeholder="com.example.record" 251 + /> 252 + </div> 253 + )} 254 + </div> 255 + 256 + {resolving && ( 257 + <Empty> 258 + <EmptyDescription>{"Resolving lexicon..."}</EmptyDescription> 259 + </Empty> 260 + )} 261 + 262 + {Boolean(nsid) && !resolving && !networkJson && ( 263 + <Empty> 264 + <EmptyTitle>{"Not found"}</EmptyTitle> 265 + 266 + <EmptyDescription> 267 + {"There are no lexicons on the network with NSID:"} 268 + <br /> 269 + <code>{nsid}</code> 270 + </EmptyDescription> 271 + </Empty> 272 + )} 273 + 274 + {networkJson && ( 275 + <CodePanels 276 + className="flex-1 min-h-0" 277 + jsonValue={networkJson} 278 + jsonReadOnly 279 + /> 280 + )} 281 + </div> 282 + 283 + <footer className="bg-sidebar-accent flex justify-end gap-2 ps-4 pt-2 pb-1 md:px-6 md:py-4 rounded-b-md"> 284 + <Button onClick={handleAddNetwork} disabled={submitting}> 285 + {submitting ? "Adding..." : "Add"} 286 + </Button> 287 + </footer> 288 + </TabsContent> 289 + </Tabs> 290 + </div> 291 + </> 292 + ); 293 + }
+23 -391
web/src/app/(dashboard)/lexicons/page.tsx
··· 14 14 getSortedRowModel, 15 15 useReactTable, 16 16 } from "@tanstack/react-table"; 17 - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 17 + import { useCallback, useEffect, useMemo, useState } from "react"; 18 + import Link from "next/link"; 18 19 19 20 import { useAuth } from "@/lib/auth-context"; 20 - import { CodeBlock } from "@/components/code-block"; 21 21 import { 22 - addNetworkLexicon, 23 22 deleteLexicon, 24 23 deleteNetworkLexicon, 25 - getLexicon, 26 24 getLexicons, 27 - uploadLexicon, 28 - type LexiconDetail, 29 25 type LexiconSummary, 30 26 } from "@/lib/api"; 31 27 import { DataTable } from "@/components/data-table/data-table"; ··· 34 30 import { SiteHeader } from "@/components/site-header"; 35 31 import { Badge } from "@/components/ui/badge"; 36 32 import { Button } from "@/components/ui/button"; 37 - import { 38 - Dialog, 39 - DialogClose, 40 - DialogContent, 41 - DialogDescription, 42 - DialogFooter, 43 - DialogHeader, 44 - DialogTitle, 45 - DialogTrigger, 46 - } from "@/components/ui/dialog"; 47 - import { Input } from "@/components/ui/input"; 48 - import { Label } from "@/components/ui/label"; 49 - import { 50 - Select, 51 - SelectContent, 52 - SelectItem, 53 - SelectTrigger, 54 - SelectValue, 55 - } from "@/components/ui/select"; 56 - import { Switch } from "@/components/ui/switch"; 57 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 58 - import { Textarea } from "@/components/ui/textarea"; 59 33 60 34 export default function LexiconsPage() { 61 35 const { getToken } = useAuth(); 62 36 const [lexicons, setLexicons] = useState<LexiconSummary[]>([]); 63 37 const [error, setError] = useState<string | null>(null); 64 - const [viewLexicon, setViewLexicon] = useState<LexiconDetail | null>(null); 65 38 66 39 const load = useCallback(() => { 67 40 getLexicons(getToken) ··· 72 45 useEffect(() => { 73 46 load(); 74 47 }, [load]); 75 - 76 - async function handleView(id: string) { 77 - try { 78 - const detail = await getLexicon(getToken, id); 79 - setViewLexicon(detail); 80 - } catch (e: unknown) { 81 - setError(e instanceof Error ? e.message : String(e)); 82 - } 83 - } 84 48 85 49 async function handleDelete(lex: LexiconSummary) { 86 50 try { ··· 183 147 enableSorting: true, 184 148 }, 185 149 { 150 + id: "has_script", 151 + accessorKey: "has_script", 152 + header: ({ column }) => ( 153 + <DataTableColumnHeader column={column} label="Script" /> 154 + ), 155 + cell: ({ row }) => 156 + row.original.has_script ? ( 157 + <Badge variant="secondary">Lua</Badge> 158 + ) : ( 159 + "--" 160 + ), 161 + enableSorting: true, 162 + }, 163 + { 186 164 id: "backfill", 187 165 accessorKey: "backfill", 188 166 header: ({ column }) => ( ··· 207 185 header: () => <span className="sr-only">Actions</span>, 208 186 cell: ({ row }) => ( 209 187 <div className="flex justify-end gap-2"> 210 - <Button 211 - variant="outline" 212 - size="sm" 213 - onClick={() => handleView(row.original.id)} 214 - > 215 - View 188 + <Button variant="outline" size="sm" asChild> 189 + <Link href={`/lexicons/${encodeURIComponent(row.original.id)}`}> 190 + View 191 + </Link> 216 192 </Button> 217 193 <Button 218 194 variant="destructive" ··· 274 250 275 251 <DataTable table={table}> 276 252 <DataTableToolbar table={table}> 277 - <AddLexiconDialog getToken={getToken} onSuccess={load} /> 253 + <Button asChild> 254 + <Link href="/lexicons/new">Add Lexicon</Link> 255 + </Button> 278 256 </DataTableToolbar> 279 257 </DataTable> 280 - 281 - {viewLexicon && ( 282 - <Dialog open onOpenChange={() => setViewLexicon(null)}> 283 - <DialogContent className="sm:max-w-2xl"> 284 - <DialogHeader> 285 - <DialogTitle>{viewLexicon.id}</DialogTitle> 286 - <DialogDescription> 287 - Revision {viewLexicon.revision} &middot;{" "} 288 - {viewLexicon.lexicon_type} 289 - </DialogDescription> 290 - </DialogHeader> 291 - <CodeBlock 292 - code={JSON.stringify(viewLexicon.lexicon_json, null, 2)} 293 - /> 294 - </DialogContent> 295 - </Dialog> 296 - )} 297 258 </div> 298 259 </> 299 260 ); 300 261 } 301 - 302 - // --------------------------------------------------------------------------- 303 - // Unified Add Lexicon dialog 304 - // --------------------------------------------------------------------------- 305 - 306 - function AddLexiconDialog({ 307 - getToken, 308 - onSuccess, 309 - }: { 310 - getToken: () => Promise<string | null>; 311 - onSuccess: () => void; 312 - }) { 313 - const [open, setOpen] = useState(false); 314 - const [error, setError] = useState<string | null>(null); 315 - 316 - // Local state 317 - const [json, setJson] = useState(""); 318 - const [localTargetCollection, setLocalTargetCollection] = useState(""); 319 - const [action, setAction] = useState(""); 320 - const [backfill, setBackfill] = useState(true); 321 - 322 - // Network state 323 - const [nsid, setNsid] = useState(""); 324 - const [networkTargetCollection, setNetworkTargetCollection] = useState(""); 325 - const [mainType, setMainType] = useState<string | undefined>(); 326 - const [resolving, setResolving] = useState(false); 327 - const abortRef = useRef<AbortController | null>(null); 328 - 329 - const localMainType = useMemo(() => { 330 - try { 331 - const parsed = JSON.parse(json); 332 - return parsed?.defs?.main?.type as string | undefined; 333 - } catch { 334 - return undefined; 335 - } 336 - }, [json]); 337 - 338 - const showLocalTargetCollection = 339 - localMainType === "query" || localMainType === "procedure"; 340 - const showAction = localMainType === "procedure"; 341 - 342 - // Debounced NSID resolution 343 - useEffect(() => { 344 - abortRef.current?.abort(); 345 - setMainType(undefined); 346 - 347 - if (nsid.split(".").length < 3) return; 348 - 349 - const debounce = setTimeout(() => { 350 - const controller = new AbortController(); 351 - abortRef.current = controller; 352 - setResolving(true); 353 - 354 - resolveNsidType(nsid, controller.signal) 355 - .then((type) => { 356 - if (!controller.signal.aborted) setMainType(type); 357 - }) 358 - .finally(() => { 359 - if (!controller.signal.aborted) setResolving(false); 360 - }); 361 - }, 500); 362 - 363 - return () => clearTimeout(debounce); 364 - }, [nsid]); 365 - 366 - const showNetworkTargetCollection = 367 - mainType === "query" || mainType === "procedure"; 368 - 369 - function reset() { 370 - setError(null); 371 - setJson(""); 372 - setLocalTargetCollection(""); 373 - setAction(""); 374 - setBackfill(true); 375 - setNsid(""); 376 - setNetworkTargetCollection(""); 377 - setMainType(undefined); 378 - } 379 - 380 - async function handleUploadLocal() { 381 - setError(null); 382 - try { 383 - const lexiconJson = JSON.parse(json); 384 - await uploadLexicon(getToken, { 385 - lexicon_json: lexiconJson, 386 - backfill, 387 - target_collection: showLocalTargetCollection 388 - ? localTargetCollection || undefined 389 - : undefined, 390 - action: showAction ? action || undefined : undefined, 391 - }); 392 - reset(); 393 - setOpen(false); 394 - onSuccess(); 395 - } catch (e: unknown) { 396 - setError(e instanceof Error ? e.message : String(e)); 397 - } 398 - } 399 - 400 - async function handleAddNetwork() { 401 - setError(null); 402 - try { 403 - await addNetworkLexicon(getToken, { 404 - nsid, 405 - target_collection: showNetworkTargetCollection 406 - ? networkTargetCollection || undefined 407 - : undefined, 408 - }); 409 - reset(); 410 - setOpen(false); 411 - onSuccess(); 412 - } catch (e: unknown) { 413 - setError(e instanceof Error ? e.message : String(e)); 414 - } 415 - } 416 - 417 - return ( 418 - <Dialog 419 - open={open} 420 - onOpenChange={(v) => { 421 - setOpen(v); 422 - if (!v) reset(); 423 - }} 424 - > 425 - <DialogTrigger asChild> 426 - <Button>Add Lexicon</Button> 427 - </DialogTrigger> 428 - <DialogContent className="max-w-2xl"> 429 - <DialogHeader> 430 - <DialogTitle>Add Lexicon</DialogTitle> 431 - <DialogDescription> 432 - Upload a local lexicon JSON document or track one from the network. 433 - </DialogDescription> 434 - </DialogHeader> 435 - <Tabs defaultValue="local"> 436 - <TabsList className="w-full"> 437 - <TabsTrigger value="local" className="flex-1"> 438 - Local 439 - </TabsTrigger> 440 - <TabsTrigger value="network" className="flex-1"> 441 - Network 442 - </TabsTrigger> 443 - </TabsList> 444 - 445 - <TabsContent value="local"> 446 - <div className="flex min-w-0 flex-col gap-4 overflow-hidden pt-4"> 447 - {error && <p className="text-destructive text-sm">{error}</p>} 448 - <div className="flex flex-col gap-2"> 449 - <Label htmlFor="lexicon-json">Lexicon JSON</Label> 450 - <Textarea 451 - id="lexicon-json" 452 - className="font-mono text-xs" 453 - rows={12} 454 - value={json} 455 - onChange={(e) => setJson(e.target.value)} 456 - placeholder='{"lexicon": 1, "id": "com.example.record", ...}' 457 - /> 458 - </div> 459 - {showLocalTargetCollection && ( 460 - <div className="flex flex-col gap-2"> 461 - <Label htmlFor="target-collection"> 462 - Target Collection (optional) 463 - </Label> 464 - <Input 465 - id="target-collection" 466 - value={localTargetCollection} 467 - onChange={(e) => setLocalTargetCollection(e.target.value)} 468 - placeholder="com.example.record" 469 - /> 470 - </div> 471 - )} 472 - {showAction && ( 473 - <div className="flex flex-col gap-2"> 474 - <Label htmlFor="action">Action (optional)</Label> 475 - <Select value={action} onValueChange={setAction}> 476 - <SelectTrigger id="action" className="w-full"> 477 - <SelectValue placeholder="Upsert (default)" /> 478 - </SelectTrigger> 479 - <SelectContent> 480 - <SelectItem value="upsert">Upsert (default)</SelectItem> 481 - <SelectItem value="create">Create</SelectItem> 482 - <SelectItem value="update">Update</SelectItem> 483 - <SelectItem value="delete">Delete</SelectItem> 484 - </SelectContent> 485 - </Select> 486 - </div> 487 - )} 488 - <div className="flex items-center gap-2"> 489 - <Switch 490 - id="backfill" 491 - checked={backfill} 492 - onCheckedChange={setBackfill} 493 - /> 494 - <Label htmlFor="backfill">Enable backfill</Label> 495 - </div> 496 - <DialogFooter> 497 - <DialogClose asChild> 498 - <Button variant="outline">Cancel</Button> 499 - </DialogClose> 500 - <Button onClick={handleUploadLocal}>Upload</Button> 501 - </DialogFooter> 502 - </div> 503 - </TabsContent> 504 - 505 - <TabsContent value="network"> 506 - <div className="flex flex-col gap-4 pt-4"> 507 - {error && <p className="text-destructive text-sm">{error}</p>} 508 - <div className="flex flex-col gap-2"> 509 - <Label htmlFor="nsid">NSID</Label> 510 - <Input 511 - id="nsid" 512 - value={nsid} 513 - onChange={(e) => setNsid(e.target.value)} 514 - placeholder="com.example.record" 515 - /> 516 - {resolving && ( 517 - <p className="text-muted-foreground text-xs"> 518 - Resolving lexicon... 519 - </p> 520 - )} 521 - </div> 522 - {showNetworkTargetCollection && ( 523 - <div className="flex flex-col gap-2"> 524 - <Label htmlFor="nl-target-collection"> 525 - Target Collection (optional) 526 - </Label> 527 - <Input 528 - id="nl-target-collection" 529 - value={networkTargetCollection} 530 - onChange={(e) => setNetworkTargetCollection(e.target.value)} 531 - placeholder="com.example.record" 532 - /> 533 - </div> 534 - )} 535 - <DialogFooter> 536 - <DialogClose asChild> 537 - <Button variant="outline">Cancel</Button> 538 - </DialogClose> 539 - <Button onClick={handleAddNetwork}>Add</Button> 540 - </DialogFooter> 541 - </div> 542 - </TabsContent> 543 - </Tabs> 544 - </DialogContent> 545 - </Dialog> 546 - ); 547 - } 548 - 549 - // --------------------------------------------------------------------------- 550 - // Network NSID resolution helpers 551 - // --------------------------------------------------------------------------- 552 - 553 - function nsidToDomain(nsid: string): string | null { 554 - const parts = nsid.split("."); 555 - if (parts.length < 3) return null; 556 - const authority = parts.slice(0, -1).reverse(); 557 - return authority.join("."); 558 - } 559 - 560 - async function resolveNsidType( 561 - nsid: string, 562 - signal: AbortSignal, 563 - ): Promise<string | undefined> { 564 - const domain = nsidToDomain(nsid); 565 - if (!domain) return undefined; 566 - 567 - let did: string | undefined; 568 - try { 569 - const resp = await fetch(`https://${domain}/.well-known/atproto-did`, { 570 - signal, 571 - }); 572 - if (resp.ok) did = (await resp.text()).trim(); 573 - } catch (e) { 574 - if (signal.aborted) return undefined; 575 - } 576 - 577 - if (!did) { 578 - try { 579 - const resp = await fetch( 580 - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 581 - { signal }, 582 - ); 583 - if (resp.ok) { 584 - const data = await resp.json(); 585 - did = data.did; 586 - } 587 - } catch (e) { 588 - if (signal.aborted) return undefined; 589 - } 590 - } 591 - 592 - if (!did) return undefined; 593 - 594 - let pdsEndpoint: string | undefined; 595 - try { 596 - const resp = await fetch( 597 - `https://plc.directory/${encodeURIComponent(did)}`, 598 - { signal }, 599 - ); 600 - if (resp.ok) { 601 - const doc = await resp.json(); 602 - const services = doc.service as 603 - | { id: string; serviceEndpoint: string }[] 604 - | undefined; 605 - pdsEndpoint = services?.find( 606 - (s) => s.id === "#atproto_pds", 607 - )?.serviceEndpoint; 608 - } 609 - } catch (e) { 610 - if (signal.aborted) return undefined; 611 - } 612 - 613 - if (!pdsEndpoint) return undefined; 614 - 615 - try { 616 - const resp = await fetch( 617 - `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`, 618 - { signal }, 619 - ); 620 - if (resp.ok) { 621 - const data = await resp.json(); 622 - return data.value?.defs?.main?.type as string | undefined; 623 - } 624 - } catch { 625 - // Best-effort resolution 626 - } 627 - 628 - return undefined; 629 - }
+16 -22
web/src/components/code-block.tsx
··· 1 1 "use client"; 2 2 3 - import { useEffect, useState } from "react"; 4 3 import type { BundledLanguage } from "shiki/bundle/web"; 5 - import type React from "react"; 4 + import { codeToHast } from "shiki/bundle/web"; 6 5 import { cn } from "@/lib/utils"; 6 + import { ComponentProps, type ReactNode, useEffect, useState } from "react"; 7 + import { Fragment, jsx, jsxs } from "react/jsx-runtime"; 8 + import { toJsxRuntime } from "hast-util-to-jsx-runtime"; 7 9 8 10 async function highlight(code: string, lang: BundledLanguage) { 9 - const { codeToHast } = await import("shiki/bundle/web"); 10 - const { toJsxRuntime } = await import("hast-util-to-jsx-runtime"); 11 - const { Fragment, jsx, jsxs } = await import("react/jsx-runtime"); 12 - 13 11 const hast = await codeToHast(code, { 14 12 lang, 15 13 themes: { ··· 24 22 jsx, 25 23 jsxs, 26 24 components: { 27 - pre: ({ style, ...props }: React.ComponentProps<"pre">) => ( 25 + pre: ({ className, style, ...props }: ComponentProps<"pre">) => ( 28 26 <pre 29 - className="p-4 text-xs" 27 + className={cn(className, "p-4 text-xs")} 30 28 style={{ 31 29 ...style, 32 30 backgroundColor: "transparent", ··· 34 32 {...props} 35 33 /> 36 34 ), 37 - code: (props: React.ComponentProps<"code">) => ( 35 + code: (props: ComponentProps<"code">) => ( 38 36 <code className="whitespace-pre" {...props} /> 39 37 ), 40 38 }, 41 - }) as React.JSX.Element; 39 + }) as ReactNode; 42 40 } 43 41 44 42 interface CodeBlockProps { 45 43 code: string; 46 - lang?: BundledLanguage; 44 + lang?: string; 47 45 className?: string; 48 46 } 49 47 50 48 export function CodeBlock({ code, lang = "json", className }: CodeBlockProps) { 51 - const [nodes, setNodes] = useState<React.JSX.Element | null>(null); 49 + const [nodes, setNodes] = useState<ReactNode | null>(null); 52 50 53 51 useEffect(() => { 54 - void highlight(code, lang).then(setNodes); 52 + void highlight(code, lang as BundledLanguage) 53 + .then(setNodes) 54 + .catch(() => { 55 + // Unsupported language — leave plain text fallback 56 + }); 55 57 }, [code, lang]); 56 58 57 59 return ( 58 - <div 59 - className={cn( 60 - "bg-muted min-w-0 max-h-[70vh] overflow-auto rounded-md", 61 - className, 62 - )} 63 - > 60 + <div className={cn("bg-muted min-w-0 overflow-auto", className)}> 64 61 <div className="w-fit min-w-full"> 65 - <div className="bg-muted-foreground/10 sticky top-0 px-4 py-2 text-xs font-medium text-muted-foreground"> 66 - {lang} 67 - </div> 68 62 {nodes ?? ( 69 63 <pre className="p-4 text-xs"> 70 64 <code className="whitespace-pre">{code}</code>
+142
web/src/components/code-panels.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { cn } from "@/lib/utils"; 5 + import { MonacoEditor } from "@/components/monaco-editor"; 6 + import type { LuaCompletions } from "@/lib/lua-completions"; 7 + 8 + interface CodePanelsProps { 9 + jsonValue: string; 10 + onJsonChange?: (value: string) => void; 11 + jsonReadOnly?: boolean; 12 + luaValue?: string | null; 13 + onLuaChange?: (value: string) => void; 14 + luaReadOnly?: boolean; 15 + luaCompletions?: LuaCompletions; 16 + collections?: string[]; 17 + className?: string; 18 + } 19 + 20 + function Panel({ 21 + className, 22 + label, 23 + value, 24 + onChange, 25 + readOnly, 26 + lang, 27 + completions, 28 + collections, 29 + }: { 30 + className?: string; 31 + label: string; 32 + value: string; 33 + onChange?: (value: string) => void; 34 + readOnly?: boolean; 35 + lang: string; 36 + completions?: LuaCompletions; 37 + collections?: string[]; 38 + }) { 39 + return ( 40 + <div className={cn("flex flex-col min-h-0 flex-1", className)}> 41 + <div className="bg-sidebar-accent p-2 text-xs">{label}</div> 42 + <MonacoEditor 43 + className="min-h-[200px] flex-1 overflow-hidden" 44 + value={value} 45 + onChange={onChange} 46 + language={lang} 47 + readOnly={readOnly || !onChange} 48 + completions={completions} 49 + collections={collections} 50 + /> 51 + </div> 52 + ); 53 + } 54 + 55 + export function CodePanels({ 56 + jsonValue, 57 + onJsonChange, 58 + jsonReadOnly, 59 + luaValue, 60 + onLuaChange, 61 + luaReadOnly, 62 + luaCompletions, 63 + collections, 64 + className, 65 + }: CodePanelsProps) { 66 + const hasLua = luaValue != null || !!onLuaChange; 67 + const [activeTab, setActiveTab] = useState<"json" | "lua">("json"); 68 + 69 + return ( 70 + <div className={cn("flex flex-col min-h-0", className)}> 71 + {/* Narrow-screen tab switcher (only when Lua panel exists) */} 72 + {hasLua && ( 73 + <div className="lg:hidden flex gap-1 mb-4 rounded-lg bg-muted p-1"> 74 + <button 75 + className={cn( 76 + "flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", 77 + activeTab === "json" 78 + ? "bg-background text-foreground shadow-sm" 79 + : "text-muted-foreground hover:text-foreground", 80 + )} 81 + onClick={() => setActiveTab("json")} 82 + > 83 + Lexicon JSON 84 + </button> 85 + <button 86 + className={cn( 87 + "flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", 88 + activeTab === "lua" 89 + ? "bg-background text-foreground shadow-sm" 90 + : "text-muted-foreground hover:text-foreground", 91 + )} 92 + onClick={() => setActiveTab("lua")} 93 + > 94 + Lua Script 95 + </button> 96 + </div> 97 + )} 98 + 99 + {/* Single set of editors — CSS controls narrow/wide layout */} 100 + <div 101 + className="border lg:grid flex-1 min-h-0 overflow-hidden rounded-md" 102 + style={{ 103 + gridTemplateColumns: hasLua ? "1fr 1fr" : "1fr 0fr", 104 + transition: "grid-template-columns 300ms ease-in-out", 105 + }} 106 + > 107 + <Panel 108 + className={cn( 109 + /* Narrow: hide when Lua tab is active */ 110 + hasLua && activeTab !== "json" ? "hidden lg:flex" : "", 111 + /* Wide: dim inactive panel */ 112 + hasLua ? "lg:border-e lg:opacity-50 lg:focus-within:opacity-100 lg:transition-opacity" : "", 113 + )} 114 + label="Lexicon JSON" 115 + value={jsonValue} 116 + onChange={onJsonChange} 117 + readOnly={jsonReadOnly} 118 + lang="json" 119 + /> 120 + {/* Wrapper stays in the DOM so the grid column can animate closed */} 121 + <div className={cn( 122 + "overflow-hidden min-w-0 flex flex-col min-h-0", 123 + /* Narrow: hide when JSON tab is active */ 124 + hasLua && activeTab !== "lua" ? "hidden lg:flex" : hasLua ? "" : "hidden lg:flex", 125 + )}> 126 + {hasLua && ( 127 + <Panel 128 + className="lg:opacity-50 lg:focus-within:opacity-100 lg:transition-opacity min-w-[300px]" 129 + label="Lua Script" 130 + value={luaValue ?? ""} 131 + onChange={onLuaChange} 132 + readOnly={luaReadOnly} 133 + lang="lua" 134 + completions={luaCompletions} 135 + collections={collections} 136 + /> 137 + )} 138 + </div> 139 + </div> 140 + </div> 141 + ); 142 + }
+303
web/src/components/monaco-editor.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef } from "react"; 4 + import dynamic from "next/dynamic"; 5 + import { useTheme } from "next-themes"; 6 + import type { editor, Position } from "monaco-editor"; 7 + import { 8 + LUA_KEYWORDS, 9 + LUA_BUILTINS, 10 + LUA_SNIPPETS, 11 + type LuaCompletions, 12 + } from "@/lib/lua-completions"; 13 + import { lexiconJsonSchema, LEXICON_SCHEMA_URI } from "@/lib/lexicon-schema"; 14 + import { resolveCssColor } from "@/lib/css-utils"; 15 + import { parseLuaIdentifiers, parseRecordVariables } from "@/lib/lua-parser"; 16 + 17 + const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); 18 + 19 + interface MonacoEditorProps { 20 + value: string; 21 + onChange?: (value: string) => void; 22 + language: string; 23 + readOnly?: boolean; 24 + className?: string; 25 + completions?: LuaCompletions; 26 + collections?: string[]; 27 + tabSize?: number; 28 + } 29 + 30 + export function MonacoEditor({ 31 + value, 32 + onChange, 33 + language, 34 + readOnly, 35 + className, 36 + completions, 37 + collections, 38 + tabSize = 2, 39 + }: MonacoEditorProps) { 40 + const { resolvedTheme } = useTheme(); 41 + const completionsRef = useRef(completions); 42 + const collectionsRef = useRef(collections); 43 + const disposablesRef = useRef<{ dispose(): void }[]>([]); 44 + 45 + // Sync refs during render (not in useEffect) so the completion 46 + // provider closure always reads the latest values immediately. 47 + completionsRef.current = completions; 48 + collectionsRef.current = collections; 49 + 50 + useEffect(() => { 51 + return () => { 52 + for (const d of disposablesRef.current) d.dispose(); 53 + disposablesRef.current = []; 54 + }; 55 + }, []); 56 + 57 + return ( 58 + <div className={`relative ${className ?? ""}`}> 59 + <div className="absolute inset-0"> 60 + <Editor 61 + height="100%" 62 + language={language} 63 + value={value} 64 + theme={resolvedTheme === "dark" ? "happyview-dark" : "vs"} 65 + onChange={(v) => onChange?.(v ?? "")} 66 + loading="Loading editor..." 67 + path={language === "json" ? "lexicon.json" : undefined} 68 + beforeMount={(monaco) => { 69 + const bg = resolveCssColor("var(--sidebar)"); 70 + monaco.editor.defineTheme("happyview-dark", { 71 + base: "vs-dark", 72 + inherit: true, 73 + rules: [], 74 + colors: { "editor.background": bg }, 75 + }); 76 + 77 + // Configure JSON language service with Lexicon schema 78 + if (language === "json") { 79 + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ 80 + validate: true, 81 + allowComments: false, 82 + trailingCommas: "error", 83 + schemas: [ 84 + { 85 + uri: LEXICON_SCHEMA_URI, 86 + fileMatch: ["lexicon.json"], 87 + schema: lexiconJsonSchema, 88 + }, 89 + ], 90 + }); 91 + } 92 + }} 93 + onMount={(_editor, monaco) => { 94 + if (language !== "lua") return; 95 + 96 + // Provider 1: Lua keywords, builtins, and snippets 97 + disposablesRef.current.push( 98 + monaco.languages.registerCompletionItemProvider("lua", { 99 + provideCompletionItems( 100 + model: editor.ITextModel, 101 + position: Position, 102 + ) { 103 + const word = model.getWordUntilPosition(position); 104 + const range = { 105 + startLineNumber: position.lineNumber, 106 + endLineNumber: position.lineNumber, 107 + startColumn: word.startColumn, 108 + endColumn: word.endColumn, 109 + }; 110 + 111 + const lineContent = model.getLineContent(position.lineNumber); 112 + const textBeforeCursor = lineContent.substring( 113 + 0, 114 + position.column - 1, 115 + ); 116 + 117 + // Don't suggest keywords after . or : (those are member access) 118 + if (/[.:]$/.test(textBeforeCursor.trimEnd())) { 119 + return { suggestions: [] }; 120 + } 121 + 122 + // Extract identifiers from the current document 123 + const staticLabels = new Set([ 124 + ...LUA_KEYWORDS, 125 + ...LUA_BUILTINS, 126 + ...LUA_SNIPPETS.map((s) => s.label), 127 + ]); 128 + const fullSource = model.getValue(); 129 + const identifiers = parseLuaIdentifiers(fullSource); 130 + // Remove the word currently being typed 131 + if (word.word) identifiers.delete(word.word); 132 + 133 + const suggestions = [ 134 + ...LUA_KEYWORDS.map((kw) => ({ 135 + label: kw, 136 + kind: monaco.languages.CompletionItemKind.Keyword, 137 + insertText: kw, 138 + detail: "keyword", 139 + range, 140 + })), 141 + ...LUA_BUILTINS.map((fn) => ({ 142 + label: fn, 143 + kind: monaco.languages.CompletionItemKind.Function, 144 + insertText: fn, 145 + detail: "function", 146 + range, 147 + })), 148 + ...LUA_SNIPPETS.map((snip) => ({ 149 + label: snip.label, 150 + kind: monaco.languages.CompletionItemKind.Snippet, 151 + insertText: snip.insertText, 152 + insertTextRules: 153 + monaco.languages.CompletionItemInsertTextRule 154 + .InsertAsSnippet, 155 + detail: snip.detail, 156 + documentation: snip.description, 157 + range, 158 + })), 159 + ...[...identifiers] 160 + .filter((id) => !staticLabels.has(id)) 161 + .map((id) => ({ 162 + label: id, 163 + kind: monaco.languages.CompletionItemKind.Variable, 164 + insertText: id, 165 + detail: "identifier", 166 + range, 167 + })), 168 + ]; 169 + 170 + return { suggestions }; 171 + }, 172 + }), 173 + ); 174 + 175 + // Provider 2: HappyView-specific completions (Record, db, collections) 176 + if (!completions) return; 177 + disposablesRef.current.push( 178 + monaco.languages.registerCompletionItemProvider("lua", { 179 + triggerCharacters: [".", ":", '"'], 180 + provideCompletionItems( 181 + model: editor.ITextModel, 182 + position: Position, 183 + ) { 184 + const lineContent = model.getLineContent(position.lineNumber); 185 + const textBeforeCursor = lineContent.substring( 186 + 0, 187 + position.column - 1, 188 + ); 189 + 190 + // Record("...") collection completions 191 + const recordMatch = 192 + textBeforeCursor.match(/Record\(\s*"([^"]*)$/); 193 + if (recordMatch) { 194 + const cols = collectionsRef.current; 195 + if (!cols?.length) return { suggestions: [] }; 196 + const quoteCol = textBeforeCursor.lastIndexOf('"'); 197 + const range = { 198 + startLineNumber: position.lineNumber, 199 + endLineNumber: position.lineNumber, 200 + startColumn: quoteCol + 2, // after the opening " 201 + endColumn: position.column, 202 + }; 203 + return { 204 + suggestions: cols.map((col) => ({ 205 + label: col, 206 + kind: monaco.languages.CompletionItemKind.Value, 207 + insertText: col, 208 + range, 209 + })), 210 + }; 211 + } 212 + 213 + // Dot or colon-triggered completions (Record., r:, db., etc.) 214 + const dotMatch = textBeforeCursor.match(/(\w+)\.\w*$/); 215 + const colonMatch = textBeforeCursor.match(/(\w+):\w*$/); 216 + const match = dotMatch || colonMatch; 217 + const isColon = !!colonMatch; 218 + if (!match) return { suggestions: [] }; 219 + 220 + const prefix = match[1]; 221 + 222 + // Build entries based on context 223 + let entries = completionsRef.current?.[prefix]; 224 + 225 + if (!entries && prefix !== "Record" && prefix !== "db") { 226 + // Variable access — check if it's a Record variable 227 + const fullSource = model.getValue(); 228 + const varMap = parseRecordVariables(fullSource); 229 + const collection = varMap[prefix]; 230 + 231 + if (collection) { 232 + // Record instance methods/fields 233 + const instanceEntries = 234 + completionsRef.current?.["Record"]?.filter( 235 + (e) => 236 + e.detail === "method" || e.detail?.endsWith("?"), 237 + ) ?? []; 238 + 239 + // Collection-specific record properties (merged into completions by parent) 240 + const schemaEntries = 241 + completionsRef.current?.[collection] ?? []; 242 + 243 + entries = [...instanceEntries, ...schemaEntries]; 244 + } 245 + } 246 + 247 + if (isColon && !entries) { 248 + // Colon on unknown variable — show Record instance methods + string methods 249 + const recordMethods = 250 + completionsRef.current?.["Record"]?.filter( 251 + (e) => e.detail === "method", 252 + ) ?? []; 253 + const stringMethods = 254 + completionsRef.current?.["string"] ?? []; 255 + entries = [...recordMethods, ...stringMethods]; 256 + } 257 + 258 + if (!entries?.length) return { suggestions: [] }; 259 + 260 + const word = model.getWordUntilPosition(position); 261 + const range = { 262 + startLineNumber: position.lineNumber, 263 + endLineNumber: position.lineNumber, 264 + startColumn: word.startColumn, 265 + endColumn: word.endColumn, 266 + }; 267 + 268 + return { 269 + suggestions: entries.map((entry) => ({ 270 + label: entry.label, 271 + kind: 272 + entry.detail === "method" || entry.detail === "function" 273 + ? monaco.languages.CompletionItemKind.Method 274 + : monaco.languages.CompletionItemKind.Field, 275 + detail: entry.detail, 276 + documentation: entry.description, 277 + insertText: entry.label, 278 + range, 279 + })), 280 + }; 281 + }, 282 + }), 283 + ); 284 + }} 285 + options={{ 286 + readOnly, 287 + minimap: { enabled: false }, 288 + automaticLayout: true, 289 + scrollBeyondLastLine: false, 290 + wordWrap: "on", 291 + fontSize: 12, 292 + tabSize, 293 + snippetSuggestions: "inline", 294 + renderLineHighlight: readOnly ? "none" : "line", 295 + hideCursorInOverviewRuler: readOnly, 296 + overviewRulerLanes: readOnly ? 0 : 3, 297 + quickSuggestions: true, 298 + }} 299 + /> 300 + </div> 301 + </div> 302 + ); 303 + }
+19 -1
web/src/components/site-header.tsx
··· 1 1 "use client" 2 2 3 + import { IconArrowLeft } from "@tabler/icons-react" 4 + import Link from "next/link" 5 + 6 + import { Button } from "@/components/ui/button" 3 7 import { Separator } from "@/components/ui/separator" 4 8 import { SidebarTrigger } from "@/components/ui/sidebar" 5 9 import { ThemeToggle } from "@/components/theme-toggle" 6 10 7 - export function SiteHeader({ title }: { title: string }) { 11 + export function SiteHeader({ 12 + title, 13 + backHref, 14 + }: { 15 + title: string 16 + backHref?: string 17 + }) { 8 18 return ( 9 19 <header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"> 10 20 <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6"> ··· 13 23 orientation="vertical" 14 24 className="mx-2 data-[orientation=vertical]:h-4" 15 25 /> 26 + {backHref && ( 27 + <Button variant="ghost" size="icon" className="-ml-1 size-7" asChild> 28 + <Link href={backHref}> 29 + <IconArrowLeft className="size-4" /> 30 + <span className="sr-only">Back</span> 31 + </Link> 32 + </Button> 33 + )} 16 34 <h1 className="text-base font-medium">{title}</h1> 17 35 <div className="ml-auto"> 18 36 <ThemeToggle />
+104
web/src/components/ui/empty.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority" 2 + 3 + import { cn } from "@/lib/utils" 4 + 5 + function Empty({ className, ...props }: React.ComponentProps<"div">) { 6 + return ( 7 + <div 8 + data-slot="empty" 9 + className={cn( 10 + "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12", 11 + className 12 + )} 13 + {...props} 14 + /> 15 + ) 16 + } 17 + 18 + function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { 19 + return ( 20 + <div 21 + data-slot="empty-header" 22 + className={cn( 23 + "flex max-w-sm flex-col items-center gap-2 text-center", 24 + className 25 + )} 26 + {...props} 27 + /> 28 + ) 29 + } 30 + 31 + const emptyMediaVariants = cva( 32 + "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", 33 + { 34 + variants: { 35 + variant: { 36 + default: "bg-transparent", 37 + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", 38 + }, 39 + }, 40 + defaultVariants: { 41 + variant: "default", 42 + }, 43 + } 44 + ) 45 + 46 + function EmptyMedia({ 47 + className, 48 + variant = "default", 49 + ...props 50 + }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) { 51 + return ( 52 + <div 53 + data-slot="empty-icon" 54 + data-variant={variant} 55 + className={cn(emptyMediaVariants({ variant, className }))} 56 + {...props} 57 + /> 58 + ) 59 + } 60 + 61 + function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { 62 + return ( 63 + <div 64 + data-slot="empty-title" 65 + className={cn("text-lg font-medium tracking-tight", className)} 66 + {...props} 67 + /> 68 + ) 69 + } 70 + 71 + function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { 72 + return ( 73 + <div 74 + data-slot="empty-description" 75 + className={cn( 76 + "text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", 77 + className 78 + )} 79 + {...props} 80 + /> 81 + ) 82 + } 83 + 84 + function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { 85 + return ( 86 + <div 87 + data-slot="empty-content" 88 + className={cn( 89 + "flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance", 90 + className 91 + )} 92 + {...props} 93 + /> 94 + ) 95 + } 96 + 97 + export { 98 + Empty, 99 + EmptyHeader, 100 + EmptyTitle, 101 + EmptyDescription, 102 + EmptyContent, 103 + EmptyMedia, 104 + }
+68
web/src/hooks/use-lua-completions.ts
··· 1 + import { useEffect, useMemo, useState } from "react"; 2 + import { useAuth } from "@/lib/auth-context"; 3 + import { getLexicon, getLexicons } from "@/lib/api"; 4 + import { 5 + buildCollectionSchemas, 6 + extractLuaCompletions, 7 + extractSchemaProperties, 8 + type CollectionSchemas, 9 + type LuaCompletions, 10 + } from "@/lib/lua-completions"; 11 + 12 + /** 13 + * Shared hook that builds Lua completions from the current JSON text, 14 + * API-fetched collection schemas, and the live record schema. 15 + */ 16 + export function useLuaCompletions(jsonText: string): { 17 + luaCompletions: LuaCompletions; 18 + collections: string[]; 19 + } { 20 + const { getToken } = useAuth(); 21 + const [collections, setCollections] = useState<string[]>([]); 22 + const [collectionSchemas, setCollectionSchemas] = 23 + useState<CollectionSchemas>({}); 24 + 25 + useEffect(() => { 26 + getLexicons(getToken).then(async (lexicons) => { 27 + const records = lexicons.filter((l) => l.lexicon_type === "record"); 28 + setCollections(records.map((l) => l.id)); 29 + 30 + // Fetch individual details to get full lexicon_json for schema extraction 31 + const details = []; 32 + for (const rec of records) { 33 + try { 34 + details.push(await getLexicon(getToken, rec.id)); 35 + } catch { 36 + // skip failed fetches 37 + } 38 + } 39 + setCollectionSchemas(buildCollectionSchemas(details)); 40 + }); 41 + }, [getToken]); 42 + 43 + const luaCompletions = useMemo(() => { 44 + const completions = extractLuaCompletions(jsonText); 45 + 46 + // Merge collection schemas from the API 47 + for (const [nsid, entries] of Object.entries(collectionSchemas)) { 48 + completions[nsid] = entries; 49 + } 50 + 51 + // Merge live record schema from the current JSON editor 52 + try { 53 + const parsed = JSON.parse(jsonText); 54 + const nsid = parsed?.id; 55 + const mainDef = parsed?.defs?.main; 56 + if (nsid && mainDef?.type === "record") { 57 + const props = extractSchemaProperties(mainDef.record); 58 + if (props.length) completions[nsid] = props; 59 + } 60 + } catch { 61 + // invalid JSON — keep existing completions 62 + } 63 + 64 + return completions; 65 + }, [jsonText, collectionSchemas]); 66 + 67 + return { luaCompletions, collections }; 68 + }
+3
web/src/lib/api.ts
··· 90 90 backfill: boolean 91 91 action: string | null 92 92 target_collection: string | null 93 + has_script: boolean 93 94 source: string 94 95 authority_did: string | null 95 96 last_fetched_at: string | null ··· 99 100 100 101 export interface LexiconDetail extends LexiconSummary { 101 102 lexicon_json: Record<string, unknown> 103 + script: string | null 102 104 } 103 105 104 106 export function getLexicons(getToken: () => Promise<string | null>) { ··· 119 121 backfill?: boolean 120 122 target_collection?: string 121 123 action?: string 124 + script?: string 122 125 } 123 126 ) { 124 127 return apiFetch<{ id: string; revision: number }>("/admin/lexicons", getToken, {
+16
web/src/lib/css-utils.ts
··· 1 + /** Resolve a CSS value (including CSS variables) to a hex color string via DOM. */ 2 + export function resolveCssColor(value: string): string { 3 + const el = document.createElement("div"); 4 + el.style.backgroundColor = value; 5 + document.body.appendChild(el); 6 + const computed = getComputedStyle(el).backgroundColor; 7 + el.remove(); 8 + const canvas = document.createElement("canvas"); 9 + canvas.width = canvas.height = 1; 10 + const ctx = canvas.getContext("2d"); 11 + if (!ctx) return "#1e1e1e"; 12 + ctx.fillStyle = computed; 13 + ctx.fillRect(0, 0, 1, 1); 14 + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; 15 + return "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); 16 + }
-44
web/src/lib/data-table.ts
··· 1 1 import type { Column } from "@tanstack/react-table"; 2 - import { dataTableConfig } from "@/config/data-table"; 3 - import type { 4 - ExtendedColumnFilter, 5 - FilterOperator, 6 - FilterVariant, 7 - } from "@/types/data-table"; 8 2 9 3 export function getColumnPinningStyle<TData>({ 10 4 column, ··· 37 31 }; 38 32 } 39 33 40 - export function getFilterOperators(filterVariant: FilterVariant) { 41 - const operatorMap: Record< 42 - FilterVariant, 43 - { label: string; value: FilterOperator }[] 44 - > = { 45 - text: dataTableConfig.textOperators, 46 - number: dataTableConfig.numericOperators, 47 - range: dataTableConfig.numericOperators, 48 - date: dataTableConfig.dateOperators, 49 - dateRange: dataTableConfig.dateOperators, 50 - boolean: dataTableConfig.booleanOperators, 51 - select: dataTableConfig.selectOperators, 52 - multiSelect: dataTableConfig.multiSelectOperators, 53 - }; 54 - 55 - return operatorMap[filterVariant] ?? dataTableConfig.textOperators; 56 - } 57 - 58 - export function getDefaultFilterOperator(filterVariant: FilterVariant) { 59 - const operators = getFilterOperators(filterVariant); 60 - 61 - return operators[0]?.value ?? (filterVariant === "text" ? "iLike" : "eq"); 62 - } 63 - 64 - export function getValidFilters<TData>( 65 - filters: ExtendedColumnFilter<TData>[], 66 - ): ExtendedColumnFilter<TData>[] { 67 - return filters.filter( 68 - (filter) => 69 - filter.operator === "isEmpty" || 70 - filter.operator === "isNotEmpty" || 71 - (Array.isArray(filter.value) 72 - ? filter.value.length > 0 73 - : filter.value !== "" && 74 - filter.value !== null && 75 - filter.value !== undefined), 76 - ); 77 - }
-4
web/src/lib/dpop.ts
··· 54 54 cachedNonce = nonce 55 55 } 56 56 57 - export function getDpopNonce(): string | null { 58 - return cachedNonce 59 - } 60 - 61 57 export async function createDpopProof( 62 58 method: string, 63 59 url: string,
+579
web/src/lib/lexicon-schema.ts
··· 1 + /** 2 + * JSON Schema for AT Protocol Lexicon documents (v1). 3 + * 4 + * Based on the official spec at https://atproto.com/specs/lexicon and the 5 + * authoritative Zod validators in @atproto/lex-document. 6 + * 7 + * Used by Monaco's JSON language service to provide autocompletion, 8 + * validation, and hover documentation in the lexicon JSON editor. 9 + */ 10 + 11 + /* eslint-disable @typescript-eslint/no-explicit-any */ 12 + 13 + export const LEXICON_SCHEMA_URI = "https://atproto.com/schemas/lexicon-v1.json"; 14 + 15 + export const lexiconJsonSchema: Record<string, any> = { 16 + $schema: "http://json-schema.org/draft-07/schema#", 17 + $id: LEXICON_SCHEMA_URI, 18 + title: "AT Protocol Lexicon", 19 + description: "Schema for AT Protocol Lexicon definition documents.", 20 + type: "object", 21 + properties: { 22 + $type: { 23 + type: "string", 24 + description: "Record type identifier.", 25 + const: "com.atproto.lexicon.schema", 26 + }, 27 + lexicon: { 28 + type: "integer", 29 + description: "Lexicon language version. Must be 1.", 30 + enum: [1], 31 + }, 32 + id: { 33 + type: "string", 34 + description: 35 + "Namespaced Identifier (NSID) for this lexicon, e.g. 'app.bsky.feed.post'. Minimum 3 dot-separated segments.", 36 + pattern: 37 + "^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$", 38 + maxLength: 317, 39 + }, 40 + description: { 41 + type: "string", 42 + description: "Human-readable description of the lexicon.", 43 + }, 44 + defs: { 45 + type: "object", 46 + description: 47 + "Map of definition names to type definitions. Primary types (record, query, procedure, subscription) must use the key 'main'.", 48 + properties: { 49 + main: { $ref: "#/definitions/mainDef" }, 50 + }, 51 + additionalProperties: { $ref: "#/definitions/nonPrimaryDef" }, 52 + minProperties: 1, 53 + }, 54 + }, 55 + required: ["lexicon", "id", "defs"], 56 + additionalProperties: false, 57 + 58 + definitions: { 59 + // ─── Top-level def unions ─────────────────────────────────── 60 + 61 + mainDef: { 62 + description: "A definition under the 'main' key — can be any type.", 63 + oneOf: [ 64 + { $ref: "#/definitions/recordDef" }, 65 + { $ref: "#/definitions/queryDef" }, 66 + { $ref: "#/definitions/procedureDef" }, 67 + { $ref: "#/definitions/subscriptionDef" }, 68 + { $ref: "#/definitions/objectDef" }, 69 + { $ref: "#/definitions/arrayDef" }, 70 + { $ref: "#/definitions/tokenDef" }, 71 + { $ref: "#/definitions/booleanDef" }, 72 + { $ref: "#/definitions/integerDef" }, 73 + { $ref: "#/definitions/stringDef" }, 74 + { $ref: "#/definitions/unknownDef" }, 75 + { $ref: "#/definitions/bytesDef" }, 76 + { $ref: "#/definitions/cidLinkDef" }, 77 + { $ref: "#/definitions/blobDef" }, 78 + ], 79 + }, 80 + 81 + nonPrimaryDef: { 82 + description: 83 + "A definition under a non-'main' key — primary types (record, query, procedure, subscription) are not allowed here.", 84 + oneOf: [ 85 + { $ref: "#/definitions/objectDef" }, 86 + { $ref: "#/definitions/arrayDef" }, 87 + { $ref: "#/definitions/tokenDef" }, 88 + { $ref: "#/definitions/booleanDef" }, 89 + { $ref: "#/definitions/integerDef" }, 90 + { $ref: "#/definitions/stringDef" }, 91 + { $ref: "#/definitions/unknownDef" }, 92 + { $ref: "#/definitions/bytesDef" }, 93 + { $ref: "#/definitions/cidLinkDef" }, 94 + { $ref: "#/definitions/blobDef" }, 95 + ], 96 + }, 97 + 98 + // ─── Primary types ────────────────────────────────────────── 99 + 100 + recordDef: { 101 + type: "object", 102 + description: 103 + "A record type — stored in a user's repository. Must be the 'main' definition.", 104 + properties: { 105 + type: { type: "string", const: "record" }, 106 + description: { type: "string", description: "Human-readable description." }, 107 + key: { 108 + type: "string", 109 + description: 110 + "Record key type:\n• 'tid' — Timestamp Identifier (most common)\n• 'nsid' — Key must be a valid NSID\n• 'any' — Any valid record key string\n• 'literal:<value>' — Fixed key, e.g. 'literal:self' for singleton records", 111 + examples: ["tid", "any", "nsid", "literal:self"], 112 + }, 113 + record: { 114 + $ref: "#/definitions/objectDef", 115 + description: "Object schema defining the record's fields.", 116 + }, 117 + }, 118 + required: ["type", "record"], 119 + additionalProperties: false, 120 + }, 121 + 122 + queryDef: { 123 + type: "object", 124 + description: 125 + "An XRPC query (HTTP GET). Must be the 'main' definition.", 126 + properties: { 127 + type: { type: "string", const: "query" }, 128 + description: { type: "string", description: "Human-readable description." }, 129 + parameters: { $ref: "#/definitions/paramsDef" }, 130 + output: { $ref: "#/definitions/xrpcBody" }, 131 + errors: { $ref: "#/definitions/xrpcErrors" }, 132 + }, 133 + required: ["type"], 134 + additionalProperties: false, 135 + }, 136 + 137 + procedureDef: { 138 + type: "object", 139 + description: 140 + "An XRPC procedure (HTTP POST). Must be the 'main' definition.", 141 + properties: { 142 + type: { type: "string", const: "procedure" }, 143 + description: { type: "string", description: "Human-readable description." }, 144 + parameters: { $ref: "#/definitions/paramsDef" }, 145 + input: { $ref: "#/definitions/xrpcBody" }, 146 + output: { $ref: "#/definitions/xrpcBody" }, 147 + errors: { $ref: "#/definitions/xrpcErrors" }, 148 + }, 149 + required: ["type"], 150 + additionalProperties: false, 151 + }, 152 + 153 + subscriptionDef: { 154 + type: "object", 155 + description: 156 + "An XRPC subscription (WebSocket event stream). Must be the 'main' definition.", 157 + properties: { 158 + type: { type: "string", const: "subscription" }, 159 + description: { type: "string", description: "Human-readable description." }, 160 + parameters: { $ref: "#/definitions/paramsDef" }, 161 + message: { 162 + type: "object", 163 + description: "Schema for subscription messages.", 164 + properties: { 165 + description: { type: "string" }, 166 + schema: { $ref: "#/definitions/unionDef" }, 167 + }, 168 + required: ["schema"], 169 + additionalProperties: false, 170 + }, 171 + errors: { $ref: "#/definitions/xrpcErrors" }, 172 + }, 173 + required: ["type"], 174 + additionalProperties: false, 175 + }, 176 + 177 + // ─── Complex types ────────────────────────────────────────── 178 + 179 + objectDef: { 180 + type: "object", 181 + description: "An object with named properties.", 182 + properties: { 183 + type: { type: "string", const: "object" }, 184 + description: { type: "string", description: "Human-readable description." }, 185 + properties: { 186 + type: "object", 187 + description: "Map of property names to their type schemas.", 188 + additionalProperties: { $ref: "#/definitions/objectProperty" }, 189 + }, 190 + required: { 191 + type: "array", 192 + description: 193 + "Property names that must be present. Each must be a key in 'properties'.", 194 + items: { type: "string" }, 195 + uniqueItems: true, 196 + }, 197 + nullable: { 198 + type: "array", 199 + description: 200 + "Property names that may be null. Each must be a key in 'properties'.", 201 + items: { type: "string" }, 202 + uniqueItems: true, 203 + }, 204 + }, 205 + required: ["type", "properties"], 206 + additionalProperties: false, 207 + }, 208 + 209 + arrayDef: { 210 + type: "object", 211 + description: "An array of items of a single type.", 212 + properties: { 213 + type: { type: "string", const: "array" }, 214 + description: { type: "string", description: "Human-readable description." }, 215 + items: { 216 + $ref: "#/definitions/arrayItem", 217 + description: "Schema for each element in the array.", 218 + }, 219 + minLength: { 220 + type: "integer", 221 + description: "Minimum number of elements.", 222 + minimum: 0, 223 + }, 224 + maxLength: { 225 + type: "integer", 226 + description: "Maximum number of elements.", 227 + minimum: 0, 228 + }, 229 + }, 230 + required: ["type", "items"], 231 + additionalProperties: false, 232 + }, 233 + 234 + tokenDef: { 235 + type: "object", 236 + description: 237 + "A named symbol with no data representation. Used as values in 'knownValues' string enumerations.", 238 + properties: { 239 + type: { type: "string", const: "token" }, 240 + description: { type: "string", description: "Clarifies the token's meaning." }, 241 + }, 242 + required: ["type"], 243 + additionalProperties: false, 244 + }, 245 + 246 + // ─── Primitive types ──────────────────────────────────────── 247 + 248 + booleanDef: { 249 + type: "object", 250 + description: "A boolean value.", 251 + properties: { 252 + type: { type: "string", const: "boolean" }, 253 + description: { type: "string" }, 254 + default: { type: "boolean", description: "Default value. Mutually exclusive with 'const'." }, 255 + const: { type: "boolean", description: "Fixed value. Mutually exclusive with 'default'." }, 256 + }, 257 + required: ["type"], 258 + additionalProperties: false, 259 + }, 260 + 261 + integerDef: { 262 + type: "object", 263 + description: "A 64-bit signed integer (limit to 53-bit for JS compatibility).", 264 + properties: { 265 + type: { type: "string", const: "integer" }, 266 + description: { type: "string" }, 267 + default: { type: "integer", description: "Default value. Mutually exclusive with 'const'." }, 268 + minimum: { type: "integer", description: "Minimum allowed value (inclusive)." }, 269 + maximum: { type: "integer", description: "Maximum allowed value (inclusive)." }, 270 + enum: { 271 + type: "array", 272 + items: { type: "integer" }, 273 + description: "Closed set of allowed values.", 274 + uniqueItems: true, 275 + }, 276 + const: { type: "integer", description: "Fixed value. Mutually exclusive with 'default'." }, 277 + }, 278 + required: ["type"], 279 + additionalProperties: false, 280 + }, 281 + 282 + stringDef: { 283 + type: "object", 284 + description: "A UTF-8 string value.", 285 + properties: { 286 + type: { type: "string", const: "string" }, 287 + description: { type: "string" }, 288 + format: { 289 + type: "string", 290 + description: "Semantic format constraint for the string value.", 291 + enum: [ 292 + "datetime", 293 + "uri", 294 + "at-uri", 295 + "did", 296 + "handle", 297 + "at-identifier", 298 + "nsid", 299 + "cid", 300 + "language", 301 + "tid", 302 + "record-key", 303 + ], 304 + }, 305 + default: { type: "string", description: "Default value. Mutually exclusive with 'const'." }, 306 + minLength: { type: "integer", description: "Minimum length in UTF-8 bytes.", minimum: 0 }, 307 + maxLength: { type: "integer", description: "Maximum length in UTF-8 bytes.", minimum: 0 }, 308 + minGraphemes: { 309 + type: "integer", 310 + description: "Minimum length in Unicode grapheme clusters.", 311 + minimum: 0, 312 + }, 313 + maxGraphemes: { 314 + type: "integer", 315 + description: "Maximum length in Unicode grapheme clusters.", 316 + minimum: 0, 317 + }, 318 + enum: { 319 + type: "array", 320 + items: { type: "string" }, 321 + description: "Closed set of allowed values.", 322 + uniqueItems: true, 323 + }, 324 + const: { type: "string", description: "Fixed value. Mutually exclusive with 'default'." }, 325 + knownValues: { 326 + type: "array", 327 + items: { type: "string" }, 328 + description: 329 + "Suggested values (open set, not strictly enforced). Entries may be token references.", 330 + uniqueItems: true, 331 + }, 332 + }, 333 + required: ["type"], 334 + additionalProperties: false, 335 + }, 336 + 337 + unknownDef: { 338 + type: "object", 339 + description: 340 + "Accepts any valid data model value. Not recommended for record definitions.", 341 + properties: { 342 + type: { type: "string", const: "unknown" }, 343 + description: { type: "string" }, 344 + }, 345 + required: ["type"], 346 + additionalProperties: false, 347 + }, 348 + 349 + // ─── IPLD types ───────────────────────────────────────────── 350 + 351 + bytesDef: { 352 + type: "object", 353 + description: 354 + "Raw bytes. Encoded in JSON as { \"$bytes\": \"<base64>\" }.", 355 + properties: { 356 + type: { type: "string", const: "bytes" }, 357 + description: { type: "string" }, 358 + minLength: { type: "integer", description: "Minimum byte length.", minimum: 0 }, 359 + maxLength: { type: "integer", description: "Maximum byte length.", minimum: 0 }, 360 + }, 361 + required: ["type"], 362 + additionalProperties: false, 363 + }, 364 + 365 + cidLinkDef: { 366 + type: "object", 367 + description: 368 + "A CID link to content-addressed data. Encoded in JSON as { \"$link\": \"<CID>\" }.", 369 + properties: { 370 + type: { type: "string", const: "cid-link" }, 371 + description: { type: "string" }, 372 + }, 373 + required: ["type"], 374 + additionalProperties: false, 375 + }, 376 + 377 + // ─── Reference types ──────────────────────────────────────── 378 + 379 + refDef: { 380 + type: "object", 381 + description: 382 + "A reference to another definition. Use '#name' for local refs or 'com.example.lexicon#name' for external.", 383 + properties: { 384 + type: { type: "string", const: "ref" }, 385 + description: { type: "string" }, 386 + ref: { 387 + type: "string", 388 + description: 389 + "Reference string. Local: '#defName'. External: 'com.example.lexicon' or 'com.example.lexicon#defName'.", 390 + examples: ["#myObject", "com.atproto.repo.strongRef"], 391 + }, 392 + }, 393 + required: ["type", "ref"], 394 + additionalProperties: false, 395 + }, 396 + 397 + unionDef: { 398 + type: "object", 399 + description: 400 + "A discriminated union of object/record types. Each variant must include a '$type' field in encoded data.", 401 + properties: { 402 + type: { type: "string", const: "union" }, 403 + description: { type: "string" }, 404 + refs: { 405 + type: "array", 406 + items: { type: "string" }, 407 + description: 408 + "References to object or record definitions that are members of this union.", 409 + minItems: 1, 410 + }, 411 + closed: { 412 + type: "boolean", 413 + description: 414 + "If true, the union is closed — only the listed refs are valid. Defaults to false (open union).", 415 + default: false, 416 + }, 417 + }, 418 + required: ["type", "refs"], 419 + additionalProperties: false, 420 + }, 421 + 422 + // ─── Blob type ────────────────────────────────────────────── 423 + 424 + blobDef: { 425 + type: "object", 426 + description: "A binary blob (e.g. image, video).", 427 + properties: { 428 + type: { type: "string", const: "blob" }, 429 + description: { type: "string" }, 430 + accept: { 431 + type: "array", 432 + items: { type: "string" }, 433 + description: 434 + "Accepted MIME types. Use '*/*' for any. Glob suffix allowed, e.g. 'image/*'.", 435 + examples: [["image/png", "image/jpeg"], ["image/*"], ["*/*"]], 436 + }, 437 + maxSize: { 438 + type: "integer", 439 + description: "Maximum blob size in bytes.", 440 + minimum: 0, 441 + }, 442 + }, 443 + required: ["type"], 444 + additionalProperties: false, 445 + }, 446 + 447 + // ─── Composite property unions ────────────────────────────── 448 + 449 + objectProperty: { 450 + description: "A property in an object definition.", 451 + oneOf: [ 452 + { $ref: "#/definitions/booleanDef" }, 453 + { $ref: "#/definitions/integerDef" }, 454 + { $ref: "#/definitions/stringDef" }, 455 + { $ref: "#/definitions/unknownDef" }, 456 + { $ref: "#/definitions/bytesDef" }, 457 + { $ref: "#/definitions/cidLinkDef" }, 458 + { $ref: "#/definitions/refDef" }, 459 + { $ref: "#/definitions/unionDef" }, 460 + { $ref: "#/definitions/blobDef" }, 461 + { $ref: "#/definitions/arrayDef" }, 462 + ], 463 + }, 464 + 465 + arrayItem: { 466 + description: 467 + "Valid types for array items. Note: nested arrays and inline objects are not allowed — use a ref to an object definition instead.", 468 + oneOf: [ 469 + { $ref: "#/definitions/booleanDef" }, 470 + { $ref: "#/definitions/integerDef" }, 471 + { $ref: "#/definitions/stringDef" }, 472 + { $ref: "#/definitions/unknownDef" }, 473 + { $ref: "#/definitions/bytesDef" }, 474 + { $ref: "#/definitions/cidLinkDef" }, 475 + { $ref: "#/definitions/refDef" }, 476 + { $ref: "#/definitions/unionDef" }, 477 + { $ref: "#/definitions/blobDef" }, 478 + ], 479 + }, 480 + 481 + // ─── XRPC helpers ─────────────────────────────────────────── 482 + 483 + paramsDef: { 484 + type: "object", 485 + description: "XRPC query/procedure parameters (HTTP query string).", 486 + properties: { 487 + type: { type: "string", const: "params" }, 488 + description: { type: "string" }, 489 + required: { 490 + type: "array", 491 + items: { type: "string" }, 492 + description: "Required parameter names. Each must be a key in 'properties'.", 493 + uniqueItems: true, 494 + }, 495 + properties: { 496 + type: "object", 497 + description: 498 + "Map of parameter names to schemas. Only primitives and primitive arrays are allowed.", 499 + additionalProperties: { $ref: "#/definitions/paramProperty" }, 500 + }, 501 + }, 502 + required: ["type", "properties"], 503 + additionalProperties: false, 504 + }, 505 + 506 + paramProperty: { 507 + description: 508 + "Valid types for XRPC parameters: boolean, integer, string, unknown, or an array of primitives.", 509 + oneOf: [ 510 + { $ref: "#/definitions/booleanDef" }, 511 + { $ref: "#/definitions/integerDef" }, 512 + { $ref: "#/definitions/stringDef" }, 513 + { $ref: "#/definitions/unknownDef" }, 514 + { $ref: "#/definitions/primitiveArrayDef" }, 515 + ], 516 + }, 517 + 518 + primitiveArrayDef: { 519 + type: "object", 520 + description: "An array whose items are a primitive type (for use in XRPC parameters).", 521 + properties: { 522 + type: { type: "string", const: "array" }, 523 + description: { type: "string" }, 524 + items: { 525 + oneOf: [ 526 + { $ref: "#/definitions/booleanDef" }, 527 + { $ref: "#/definitions/integerDef" }, 528 + { $ref: "#/definitions/stringDef" }, 529 + { $ref: "#/definitions/unknownDef" }, 530 + ], 531 + }, 532 + minLength: { type: "integer", minimum: 0 }, 533 + maxLength: { type: "integer", minimum: 0 }, 534 + }, 535 + required: ["type", "items"], 536 + additionalProperties: false, 537 + }, 538 + 539 + xrpcBody: { 540 + type: "object", 541 + description: "XRPC request/response body definition.", 542 + properties: { 543 + description: { type: "string" }, 544 + encoding: { 545 + type: "string", 546 + description: "MIME type, e.g. 'application/json' or '*/*'.", 547 + examples: ["application/json", "*/*", "application/cbor"], 548 + }, 549 + schema: { 550 + description: "Body schema — must be an object, ref, or union.", 551 + oneOf: [ 552 + { $ref: "#/definitions/objectDef" }, 553 + { $ref: "#/definitions/refDef" }, 554 + { $ref: "#/definitions/unionDef" }, 555 + ], 556 + }, 557 + }, 558 + required: ["encoding"], 559 + additionalProperties: false, 560 + }, 561 + 562 + xrpcErrors: { 563 + type: "array", 564 + description: "Possible error responses.", 565 + items: { 566 + type: "object", 567 + properties: { 568 + name: { 569 + type: "string", 570 + description: "Short error name with no whitespace, e.g. 'InvalidRequest'.", 571 + }, 572 + description: { type: "string", description: "Human-readable error description." }, 573 + }, 574 + required: ["name"], 575 + additionalProperties: false, 576 + }, 577 + }, 578 + }, 579 + };
+226
web/src/lib/lua-completions.ts
··· 1 + export interface LuaCompletionEntry { 2 + label: string; 3 + detail?: string; 4 + description?: string; 5 + } 6 + 7 + export interface LuaSnippetEntry { 8 + label: string; 9 + insertText: string; 10 + detail: string; 11 + description?: string; 12 + } 13 + 14 + export const LUA_KEYWORDS = [ 15 + "and", "break", "do", "else", "end", "false", 16 + "in", "nil", "not", "or", "then", "true", "until", 17 + ]; 18 + 19 + export const LUA_BUILTINS = [ 20 + "print", "tostring", "tonumber", "type", "pairs", "ipairs", "next", 21 + "select", "unpack", "error", "pcall", "xpcall", "assert", 22 + "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", 23 + // HappyView sandbox globals 24 + "input", "params", "caller_did", "collection", "method", 25 + "now", "log", "TID", 26 + ]; 27 + 28 + export const LUA_SNIPPETS: LuaSnippetEntry[] = [ 29 + { 30 + label: "if", 31 + insertText: "if ${1:condition} then\n\t$0\nend", 32 + detail: "if ... then ... end", 33 + }, 34 + { 35 + label: "if", 36 + insertText: "if ${1:condition} then\n\t$2\nelse\n\t$0\nend", 37 + detail: "if ... then ... else ... end", 38 + }, 39 + { 40 + label: "if", 41 + insertText: "if ${1:condition} then\n\t$2\nelseif ${3:condition} then\n\t$0\nend", 42 + detail: "if ... then ... elseif ... end", 43 + }, 44 + { 45 + label: "elseif", 46 + insertText: "elseif ${1:condition} then\n\t$0", 47 + detail: "elseif ... then", 48 + }, 49 + { 50 + label: "for", 51 + insertText: "for ${1:i} = ${2:1}, ${3:10} do\n\t$0\nend", 52 + detail: "for i = start, stop do ... end", 53 + }, 54 + { 55 + label: "for", 56 + insertText: "for ${1:i}, ${2:v} in ipairs(${3:t}) do\n\t$0\nend", 57 + detail: "for i, v in ipairs(t) do ... end", 58 + }, 59 + { 60 + label: "for", 61 + insertText: "for ${1:k}, ${2:v} in pairs(${3:t}) do\n\t$0\nend", 62 + detail: "for k, v in pairs(t) do ... end", 63 + }, 64 + { 65 + label: "while", 66 + insertText: "while ${1:condition} do\n\t$0\nend", 67 + detail: "while ... do ... end", 68 + }, 69 + { 70 + label: "repeat", 71 + insertText: "repeat\n\t$0\nuntil ${1:condition}", 72 + detail: "repeat ... until ...", 73 + }, 74 + { 75 + label: "function", 76 + insertText: "function ${1:name}(${2:})\n\t$0\nend", 77 + detail: "function name(...) ... end", 78 + }, 79 + { 80 + label: "function", 81 + insertText: "local function ${1:name}(${2:})\n\t$0\nend", 82 + detail: "local function name(...) ... end", 83 + }, 84 + { 85 + label: "local", 86 + insertText: "local ${1:name} = ${0}", 87 + detail: "local name = ...", 88 + }, 89 + { 90 + label: "return", 91 + insertText: "return ${0}", 92 + detail: "return ...", 93 + }, 94 + ]; 95 + 96 + export type LuaCompletions = Record<string, LuaCompletionEntry[]>; 97 + 98 + /** Map of collection NSID → record property completions */ 99 + export type CollectionSchemas = Record<string, LuaCompletionEntry[]>; 100 + 101 + const STATIC_COMPLETIONS: LuaCompletions = { 102 + Record: [ 103 + { label: "save_all", detail: "function", description: "Save multiple records in parallel — Record.save_all({ r1, r2 })" }, 104 + { label: "load", detail: "function", description: "Load a record from the database by AT URI — Record.load(uri)" }, 105 + { label: "load_all", detail: "function", description: "Load multiple records from the database — Record.load_all({ uri1, uri2 })" }, 106 + { label: "save", detail: "method", description: "Save this record (creates or updates) — r:save()" }, 107 + { label: "delete", detail: "method", description: "Delete this record from PDS and database — r:delete()" }, 108 + { label: "set_key_type", detail: "method", description: "Set the record key type (tid, any, nsid, literal:*) — r:set_key_type(type)" }, 109 + { label: "set_rkey", detail: "method", description: "Set a specific rkey for this record — r:set_rkey(key)" }, 110 + { label: "generate_rkey", detail: "method", description: "Generate an rkey based on _key_type — r:generate_rkey()" }, 111 + { label: "_uri", detail: "string?", description: "AT URI of the record (set after save)" }, 112 + { label: "_cid", detail: "string?", description: "CID of the record (set after save)" }, 113 + { label: "_key_type", detail: "string?", description: "Record key type from lexicon (tid, any, nsid, literal:*)" }, 114 + { label: "_rkey", detail: "string?", description: "Record key (set via set_rkey or generate_rkey)" }, 115 + ], 116 + db: [ 117 + { label: "query", detail: "function", description: "Execute a SQL query and return rows" }, 118 + { label: "get", detail: "function", description: "Execute a SQL query and return a single row" }, 119 + { label: "count", detail: "function", description: "Execute a count query" }, 120 + ], 121 + // Lua standard library modules 122 + string: [ 123 + { label: "byte", detail: "function", description: "Returns internal numeric codes of characters — string.byte(s [, i [, j]])" }, 124 + { label: "char", detail: "function", description: "Returns a string from character codes — string.char(···)" }, 125 + { label: "find", detail: "function", description: "Find first match of pattern — string.find(s, pattern [, init [, plain]])" }, 126 + { label: "format", detail: "function", description: "Format a string — string.format(formatstring, ···)" }, 127 + { label: "gmatch", detail: "function", description: "Returns an iterator for all matches — string.gmatch(s, pattern)" }, 128 + { label: "gsub", detail: "function", description: "Global substitution — string.gsub(s, pattern, repl [, n])" }, 129 + { label: "len", detail: "function", description: "Returns the length of a string — string.len(s)" }, 130 + { label: "lower", detail: "function", description: "Returns lowercase copy — string.lower(s)" }, 131 + { label: "match", detail: "function", description: "Find first match and return captures — string.match(s, pattern [, init])" }, 132 + { label: "rep", detail: "function", description: "Returns a repeated copy — string.rep(s, n [, sep])" }, 133 + { label: "reverse", detail: "function", description: "Returns reversed string — string.reverse(s)" }, 134 + { label: "sub", detail: "function", description: "Returns a substring — string.sub(s, i [, j])" }, 135 + { label: "upper", detail: "function", description: "Returns uppercase copy — string.upper(s)" }, 136 + ], 137 + table: [ 138 + { label: "concat", detail: "function", description: "Concatenate table elements — table.concat(list [, sep [, i [, j]]])" }, 139 + { label: "insert", detail: "function", description: "Insert element — table.insert(list, [pos,] value)" }, 140 + { label: "remove", detail: "function", description: "Remove element — table.remove(list [, pos])" }, 141 + { label: "sort", detail: "function", description: "Sort table in-place — table.sort(list [, comp])" }, 142 + { label: "unpack", detail: "function", description: "Unpack table elements — table.unpack(list [, i [, j]])" }, 143 + ], 144 + math: [ 145 + { label: "abs", detail: "function", description: "Absolute value — math.abs(x)" }, 146 + { label: "ceil", detail: "function", description: "Round up — math.ceil(x)" }, 147 + { label: "floor", detail: "function", description: "Round down — math.floor(x)" }, 148 + { label: "max", detail: "function", description: "Maximum value — math.max(x, ···)" }, 149 + { label: "min", detail: "function", description: "Minimum value — math.min(x, ···)" }, 150 + { label: "random", detail: "function", description: "Generate random number — math.random([m [, n]])" }, 151 + { label: "sqrt", detail: "function", description: "Square root — math.sqrt(x)" }, 152 + { label: "huge", detail: "number", description: "Infinity value" }, 153 + { label: "pi", detail: "number", description: "Pi constant (3.14159...)" }, 154 + ], 155 + }; 156 + 157 + /** Extract property completions from a record schema object (`defs.main.record`). */ 158 + export function extractSchemaProperties( 159 + schema: Record<string, unknown> | null | undefined, 160 + ): LuaCompletionEntry[] { 161 + if (!schema) return []; 162 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 163 + const props = (schema as any)?.properties; 164 + if (!props || typeof props !== "object") return []; 165 + 166 + return Object.keys(props).map((key) => ({ 167 + label: key, 168 + detail: props[key]?.type ?? "property", 169 + description: props[key]?.description, 170 + })); 171 + } 172 + 173 + /** Build a collection → property completions map from lexicon details. 174 + * Extracts record properties from `lexicon_json.defs.main.record`. */ 175 + export function buildCollectionSchemas( 176 + lexicons: { 177 + id: string; 178 + lexicon_json?: Record<string, unknown> | null; 179 + }[], 180 + ): CollectionSchemas { 181 + const schemas: CollectionSchemas = {}; 182 + for (const lex of lexicons) { 183 + if (!lex.lexicon_json) continue; 184 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 185 + const mainDef = (lex.lexicon_json as any)?.defs?.main; 186 + if (mainDef?.type === "record") { 187 + const props = extractSchemaProperties(mainDef.record); 188 + if (props.length) schemas[lex.id] = props; 189 + } 190 + } 191 + return schemas; 192 + } 193 + 194 + export function extractLuaCompletions(lexiconJson: string): LuaCompletions { 195 + const completions: LuaCompletions = { ...STATIC_COMPLETIONS }; 196 + 197 + try { 198 + const parsed = JSON.parse(lexiconJson); 199 + const mainDef = parsed?.defs?.main; 200 + if (!mainDef) return completions; 201 + 202 + if (mainDef.type === "procedure") { 203 + const props = mainDef.input?.schema?.properties; 204 + if (props && typeof props === "object") { 205 + completions.input = Object.keys(props).map((key) => ({ 206 + label: key, 207 + detail: props[key]?.type ?? "property", 208 + description: props[key]?.description, 209 + })); 210 + } 211 + } else if (mainDef.type === "query") { 212 + const props = mainDef.parameters?.properties; 213 + if (props && typeof props === "object") { 214 + completions.params = Object.keys(props).map((key) => ({ 215 + label: key, 216 + detail: props[key]?.type ?? "property", 217 + description: props[key]?.description, 218 + })); 219 + } 220 + } 221 + } catch { 222 + // Invalid JSON — return static completions only 223 + } 224 + 225 + return completions; 226 + }
+39
web/src/lib/lua-parser.ts
··· 1 + /** Extract Lua identifier names (variables, functions, parameters). */ 2 + export function parseLuaIdentifiers(source: string): Set<string> { 3 + const ids = new Set<string>(); 4 + // local var, local var = ..., local var1, var2 = ... 5 + for (const m of source.matchAll(/\blocal\s+([\w,\s]+?)(?:\s*=|$)/gm)) { 6 + for (const name of m[1].split(",")) { 7 + const trimmed = name.trim(); 8 + if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed); 9 + } 10 + } 11 + // function name(...), local function name(...) 12 + for (const m of source.matchAll(/\bfunction\s+(\w+)\s*\(([^)]*)\)/g)) { 13 + ids.add(m[1]); 14 + for (const p of m[2].split(",")) { 15 + const trimmed = p.trim(); 16 + if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed); 17 + } 18 + } 19 + // for var [, var...] in/= 20 + for (const m of source.matchAll(/\bfor\s+([\w,\s]+?)\s+in\b/g)) { 21 + for (const name of m[1].split(",")) { 22 + const trimmed = name.trim(); 23 + if (trimmed && /^\w+$/.test(trimmed)) ids.add(trimmed); 24 + } 25 + } 26 + return ids; 27 + } 28 + 29 + /** Parse Lua source for `Record("collection")` variable assignments. */ 30 + export function parseRecordVariables(source: string): Record<string, string> { 31 + const map: Record<string, string> = {}; 32 + // Match: local var = Record("collection" and var = Record("collection" 33 + const re = /(?:local\s+)?(\w+)\s*=\s*Record\(\s*"([^"]+)"/g; 34 + let m; 35 + while ((m = re.exec(source)) !== null) { 36 + map[m[1]] = m[2]; 37 + } 38 + return map; 39 + }
+53
web/src/lib/lua-templates.ts
··· 1 + export const LEXICON_TEMPLATE = JSON.stringify( 2 + { 3 + $type: "com.atproto.lexicon.schema", 4 + lexicon: 1, 5 + id: "", 6 + defs: { 7 + main: { 8 + type: "record", 9 + key: "tid", 10 + record: { 11 + type: "object", 12 + required: [], 13 + properties: {}, 14 + }, 15 + }, 16 + }, 17 + }, 18 + null, 19 + 2, 20 + ) 21 + 22 + export function procedureScript(collection: string): string { 23 + const target = collection || "COLLECTION" 24 + return `function handle() 25 + \tlocal r = Record("${target}", input) 26 + \tr:save() 27 + \treturn { uri = r._uri, cid = r._cid } 28 + end 29 + ` 30 + } 31 + 32 + export function queryScript(collection: string): string { 33 + const target = collection || "COLLECTION" 34 + return `collection = "${target}" 35 + 36 + function handle() 37 + \tif params.uri then 38 + \t\tlocal record = db.get(params.uri) 39 + \t\tif not record then 40 + \t\t\terror("record not found") 41 + \t\tend 42 + \t\treturn { record = record } 43 + \tend 44 + 45 + \treturn db.query({ 46 + \t\tcollection = collection, 47 + \t\tdid = params.did, 48 + \t\tlimit = params.limit, 49 + \t\tcursor = params.cursor, 50 + \t}) 51 + end 52 + ` 53 + }
+86
web/src/lib/nsid.ts
··· 1 + export function nsidToDomain(nsid: string): string | null { 2 + const parts = nsid.split("."); 3 + if (parts.length < 3) return null; 4 + const authority = parts.slice(0, -1).reverse(); 5 + return authority.join("."); 6 + } 7 + 8 + export interface ResolvedNsid { 9 + type: string | undefined; 10 + lexiconJson: Record<string, unknown> | undefined; 11 + } 12 + 13 + export async function resolveNsid( 14 + nsid: string, 15 + signal: AbortSignal, 16 + ): Promise<ResolvedNsid> { 17 + const empty: ResolvedNsid = { type: undefined, lexiconJson: undefined }; 18 + const domain = nsidToDomain(nsid); 19 + if (!domain) return empty; 20 + 21 + let did: string | undefined; 22 + try { 23 + const resp = await fetch(`https://${domain}/.well-known/atproto-did`, { 24 + signal, 25 + }); 26 + if (resp.ok) did = (await resp.text()).trim(); 27 + } catch (e) { 28 + if (signal.aborted) return empty; 29 + } 30 + 31 + if (!did) { 32 + try { 33 + const resp = await fetch( 34 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 35 + { signal }, 36 + ); 37 + if (resp.ok) { 38 + const data = await resp.json(); 39 + did = data.did; 40 + } 41 + } catch (e) { 42 + if (signal.aborted) return empty; 43 + } 44 + } 45 + 46 + if (!did) return empty; 47 + 48 + let pdsEndpoint: string | undefined; 49 + try { 50 + const resp = await fetch( 51 + `https://plc.directory/${encodeURIComponent(did)}`, 52 + { signal }, 53 + ); 54 + if (resp.ok) { 55 + const doc = await resp.json(); 56 + const services = doc.service as 57 + | { id: string; serviceEndpoint: string }[] 58 + | undefined; 59 + pdsEndpoint = services?.find( 60 + (s) => s.id === "#atproto_pds", 61 + )?.serviceEndpoint; 62 + } 63 + } catch (e) { 64 + if (signal.aborted) return empty; 65 + } 66 + 67 + if (!pdsEndpoint) return empty; 68 + 69 + try { 70 + const resp = await fetch( 71 + `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`, 72 + { signal }, 73 + ); 74 + if (resp.ok) { 75 + const data = await resp.json(); 76 + const value = data.value as Record<string, unknown> | undefined; 77 + const mainType = (value?.defs as Record<string, Record<string, unknown>> | undefined) 78 + ?.main?.type as string | undefined; 79 + return { type: mainType, lexiconJson: value }; 80 + } 81 + } catch { 82 + // Best-effort resolution 83 + } 84 + 85 + return empty; 86 + }
+1 -7
web/src/types/data-table.ts
··· 1 - import type { ColumnSort, Row, RowData } from "@tanstack/react-table"; 1 + import type { ColumnSort, RowData } from "@tanstack/react-table"; 2 2 import type { DataTableConfig } from "@/config/data-table"; 3 3 import type { FilterItemSchema } from "@/lib/parsers"; 4 4 ··· 37 37 38 38 export type FilterOperator = DataTableConfig["operators"][number]; 39 39 export type FilterVariant = DataTableConfig["filterVariants"][number]; 40 - export type JoinOperator = DataTableConfig["joinOperators"][number]; 41 - 42 40 export interface ExtendedColumnSort<TData> extends Omit<ColumnSort, "id"> { 43 41 id: Extract<keyof TData, string>; 44 42 } ··· 47 45 id: Extract<keyof TData, string>; 48 46 } 49 47 50 - export interface DataTableRowAction<TData> { 51 - row: Row<TData>; 52 - variant: "update" | "delete"; 53 - }