···1+-- migrate:up
2+3+-- Add validator_js column to lexicon table for JavaScript validators
4+ALTER TABLE lexicon ADD COLUMN validator_js TEXT;
5+6+-- migrate:down
7+8+-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
9+CREATE TABLE lexicon_backup AS SELECT id, json, created_at FROM lexicon;
10+DROP TABLE lexicon;
11+CREATE TABLE lexicon (
12+ id TEXT PRIMARY KEY NOT NULL,
13+ json TEXT NOT NULL,
14+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
15+);
16+INSERT INTO lexicon SELECT * FROM lexicon_backup;
17+DROP TABLE lexicon_backup;
18+CREATE INDEX IF NOT EXISTS idx_lexicon_created_at ON lexicon(created_at DESC);
···1+-- migrate:up
2+3+-- Add validator_js column to lexicon table for JavaScript validators
4+ALTER TABLE lexicon ADD COLUMN validator_js TEXT;
5+6+-- migrate:down
7+8+ALTER TABLE lexicon DROP COLUMN validator_js;
+3-2
server/db/schema.sql
···26 id TEXT PRIMARY KEY NOT NULL,
27 json TEXT NOT NULL,
28 created_at TEXT NOT NULL DEFAULT (datetime('now'))
29-);
30CREATE INDEX idx_lexicon_created_at ON lexicon(created_at DESC);
31CREATE TABLE config (
32 key TEXT PRIMARY KEY NOT NULL,
···243 ('20241210000001'),
244 ('20241227000001'),
245 ('20251229000001'),
246- ('20251230000001');
0
···26 id TEXT PRIMARY KEY NOT NULL,
27 json TEXT NOT NULL,
28 created_at TEXT NOT NULL DEFAULT (datetime('now'))
29+, validator_js TEXT);
30CREATE INDEX idx_lexicon_created_at ON lexicon(created_at DESC);
31CREATE TABLE config (
32 key TEXT PRIMARY KEY NOT NULL,
···243 ('20241210000001'),
244 ('20241227000001'),
245 ('20251229000001'),
246+ ('20251230000001'),
247+ ('20260120000001');
···1+/// JavaScript validator execution module
2+/// Allows running JavaScript validation functions against records during ingestion
3+/// Result of running a validator
4+pub type ValidatorResult {
5+ /// Validator returned true - record is valid
6+ Valid
7+ /// Validator returned false - record is invalid
8+ Invalid
9+ /// Validator failed to execute
10+ ValidationError(String)
11+}
12+13+/// Start the persistent validator worker process
14+/// This keeps a Node.js process running for fast validation
15+/// Returns Ok(Nil) if started successfully, Error if already running or failed
16+pub fn start_worker() -> Result(Nil, String) {
17+ case do_start_worker() {
18+ Ok(_) -> Ok(Nil)
19+ Error(reason) -> Error(reason)
20+ }
21+}
22+23+/// Stop the validator worker process
24+pub fn stop_worker() -> Nil {
25+ do_stop_worker()
26+}
27+28+@external(erlang, "validator_ffi", "start_worker")
29+fn do_start_worker() -> Result(Nil, String)
30+31+@external(erlang, "validator_ffi", "stop_worker")
32+fn do_stop_worker() -> Nil
33+34+/// Run a JavaScript validator against a record JSON
35+/// The validator_js should export a default function that takes the record object
36+/// and returns true if valid, false if invalid
37+pub fn run_validator(
38+ validator_js: String,
39+ record_json: String,
40+) -> ValidatorResult {
41+ case do_run_validator(validator_js, record_json) {
42+ Ok(True) -> Valid
43+ Ok(False) -> Invalid
44+ Error(reason) -> ValidationError(reason)
45+ }
46+}
47+48+/// Validate that a JavaScript validator script is well-formed and can be executed
49+/// Tests the validator with an empty object {} to ensure it returns a boolean
50+/// Returns Ok(Nil) if valid, Error(reason) if invalid
51+pub fn validate_script(validator_js: String) -> Result(Nil, String) {
52+ do_validate_script(validator_js)
53+}
54+55+/// FFI to Erlang validator runner
56+@external(erlang, "validator_ffi", "run_validator")
57+fn do_run_validator(
58+ validator_js: String,
59+ record_json: String,
60+) -> Result(Bool, String)
61+62+/// FFI to Erlang validator script checker
63+@external(erlang, "validator_ffi", "validate_script")
64+fn do_validate_script(validator_js: String) -> Result(Nil, String)
65+66+/// Check if Node.js is available on the system
67+/// Returns True if node is available, False otherwise
68+/// The result is cached after the first check
69+pub fn is_node_available() -> Bool {
70+ do_is_node_available()
71+}
72+73+@external(erlang, "validator_ffi", "is_node_available")
74+fn do_is_node_available() -> Bool
···115116 // Insert a lexicon for xyz.statusphere.status
117 let lexicon = create_status_lexicon()
118- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0119120 // Insert some test records
121 let record1_json =
···223224 // Insert a lexicon but no records
225 let lexicon = create_simple_lexicon("xyz.statusphere.status")
226- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0227228 // Create GraphQL query request with Connection structure
229 let query =
···274275 // Insert a lexicon
276 let lexicon = create_simple_lexicon("xyz.statusphere.status")
277- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0278279 // Create GraphQL GET request with query parameter
280 let request =
···453 ])
454 |> json.to_string
455456- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon1)
457- let assert Ok(_) = lexicons.insert(exec, "app.bsky.feed.post", lexicon2)
0458459 // Insert records for first collection
460 let record1_json =
···569570 // Insert a lexicon
571 let lexicon = create_simple_lexicon("xyz.statusphere.status")
572- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0573574 // Insert 150 records (handler should limit to 100)
575 let _ =
···663664 // Insert a lexicon for xyz.statusphere.status
665 let lexicon = create_status_lexicon()
666- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0667668 // Insert test actors
669 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
···760761 // Insert a lexicon for xyz.statusphere.status
762 let lexicon = create_status_lexicon()
763- let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
0764765 // Insert test actors
766 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
···115116 // Insert a lexicon for xyz.statusphere.status
117 let lexicon = create_status_lexicon()
118+ let assert Ok(_) =
119+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
120121 // Insert some test records
122 let record1_json =
···224225 // Insert a lexicon but no records
226 let lexicon = create_simple_lexicon("xyz.statusphere.status")
227+ let assert Ok(_) =
228+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
229230 // Create GraphQL query request with Connection structure
231 let query =
···276277 // Insert a lexicon
278 let lexicon = create_simple_lexicon("xyz.statusphere.status")
279+ let assert Ok(_) =
280+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
281282 // Create GraphQL GET request with query parameter
283 let request =
···456 ])
457 |> json.to_string
458459+ let assert Ok(_) =
460+ lexicons.insert(exec, "xyz.statusphere.status", lexicon1, None)
461+ let assert Ok(_) = lexicons.insert(exec, "app.bsky.feed.post", lexicon2, None)
462463 // Insert records for first collection
464 let record1_json =
···573574 // Insert a lexicon
575 let lexicon = create_simple_lexicon("xyz.statusphere.status")
576+ let assert Ok(_) =
577+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
578579 // Insert 150 records (handler should limit to 100)
580 let _ =
···668669 // Insert a lexicon for xyz.statusphere.status
670 let lexicon = create_status_lexicon()
671+ let assert Ok(_) =
672+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
673674 // Insert test actors
675 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
···766767 // Insert a lexicon for xyz.statusphere.status
768 let lexicon = create_status_lexicon()
769+ let assert Ok(_) =
770+ lexicons.insert(exec, "xyz.statusphere.status", lexicon, None)
771772 // Insert test actors
773 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
···62 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
63 // Insert a minimal lexicon so schema can build
64 let assert Ok(_) =
65- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
000006667 // Query for viewer label preferences
68 let query =
···116 let assert Ok(_) = test_helpers.create_moderation_tables(exec)
117 // Insert a minimal lexicon so schema can build
118 let assert Ok(_) =
119- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000120121 // Query WITHOUT auth token
122 let query =
···152 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
153 // Insert a minimal lexicon so schema can build
154 let assert Ok(_) =
155- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000156157 // Set preference for 'spam' label to 'hide'
158 let mutation =
···225 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
226 // Insert a minimal lexicon so schema can build
227 let assert Ok(_) =
228- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000229230 // Try to set preference for system label '!takedown'
231 let mutation =
···265 let assert Ok(_) = test_helpers.create_moderation_tables(exec)
266 // Insert a minimal lexicon so schema can build
267 let assert Ok(_) =
268- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000269270 // Try to set preference WITHOUT auth token
271 let mutation =
···306 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
307 // Insert a minimal lexicon so schema can build
308 let assert Ok(_) =
309- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000310311 // Try to set invalid visibility (GraphQL enum validation should catch this)
312 let mutation =
···347 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
348 // Insert a minimal lexicon so schema can build
349 let assert Ok(_) =
350- lexicons.insert(exec, "test.minimal.record", create_minimal_lexicon())
00000351352 // Try to set preference for non-existent label
353 let mutation =
···62 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
63 // Insert a minimal lexicon so schema can build
64 let assert Ok(_) =
65+ lexicons.insert(
66+ exec,
67+ "test.minimal.record",
68+ create_minimal_lexicon(),
69+ option.None,
70+ )
7172 // Query for viewer label preferences
73 let query =
···121 let assert Ok(_) = test_helpers.create_moderation_tables(exec)
122 // Insert a minimal lexicon so schema can build
123 let assert Ok(_) =
124+ lexicons.insert(
125+ exec,
126+ "test.minimal.record",
127+ create_minimal_lexicon(),
128+ option.None,
129+ )
130131 // Query WITHOUT auth token
132 let query =
···162 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
163 // Insert a minimal lexicon so schema can build
164 let assert Ok(_) =
165+ lexicons.insert(
166+ exec,
167+ "test.minimal.record",
168+ create_minimal_lexicon(),
169+ option.None,
170+ )
171172 // Set preference for 'spam' label to 'hide'
173 let mutation =
···240 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
241 // Insert a minimal lexicon so schema can build
242 let assert Ok(_) =
243+ lexicons.insert(
244+ exec,
245+ "test.minimal.record",
246+ create_minimal_lexicon(),
247+ option.None,
248+ )
249250 // Try to set preference for system label '!takedown'
251 let mutation =
···285 let assert Ok(_) = test_helpers.create_moderation_tables(exec)
286 // Insert a minimal lexicon so schema can build
287 let assert Ok(_) =
288+ lexicons.insert(
289+ exec,
290+ "test.minimal.record",
291+ create_minimal_lexicon(),
292+ option.None,
293+ )
294295 // Try to set preference WITHOUT auth token
296 let mutation =
···331 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
332 // Insert a minimal lexicon so schema can build
333 let assert Ok(_) =
334+ lexicons.insert(
335+ exec,
336+ "test.minimal.record",
337+ create_minimal_lexicon(),
338+ option.None,
339+ )
340341 // Try to set invalid visibility (GraphQL enum validation should catch this)
342 let mutation =
···377 test_helpers.insert_test_token(exec, "test-viewer-token", "did:plc:viewer")
378 // Insert a minimal lexicon so schema can build
379 let assert Ok(_) =
380+ lexicons.insert(
381+ exec,
382+ "test.minimal.record",
383+ create_minimal_lexicon(),
384+ option.None,
385+ )
386387 // Try to set preference for non-existent label
388 let mutation =
···1import database/executor.{type Executor}
2import database/repositories/lexicons
3import gleam/http
4-import gleam/option
5import gleam/string
6import gleeunit/should
7import handlers/mcp
···78 // Insert a test lexicon
79 let lexicon_json =
80 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
81- let assert Ok(_) = lexicons.insert(exec, "test.example.status", lexicon_json)
08283 let body =
84 "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_lexicon\",\"arguments\":{\"nsid\":\"test.example.status\"}},\"id\":3}"
···100 // Insert a test lexicon so schema builds
101 let lexicon_json =
102 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}"
103- let assert Ok(_) = lexicons.insert(exec, "test.example.status", lexicon_json)
0104105 let body =
106 "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"execute_query\",\"arguments\":{\"query\":\"{ __typename }\"}},\"id\":4}"
···1import database/executor.{type Executor}
2import database/repositories/lexicons
3import gleam/http
4+import gleam/option.{None}
5import gleam/string
6import gleeunit/should
7import handlers/mcp
···78 // Insert a test lexicon
79 let lexicon_json =
80 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
81+ let assert Ok(_) =
82+ lexicons.insert(exec, "test.example.status", lexicon_json, None)
8384 let body =
85 "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_lexicon\",\"arguments\":{\"nsid\":\"test.example.status\"}},\"id\":3}"
···101 // Insert a test lexicon so schema builds
102 let lexicon_json =
103 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}"
104+ let assert Ok(_) =
105+ lexicons.insert(exec, "test.example.status", lexicon_json, None)
106107 let body =
108 "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"execute_query\",\"arguments\":{\"query\":\"{ __typename }\"}},\"id\":4}"
+3-2
server/test/mcp/tools/graphql_test.gleam
···1import database/executor.{type Executor}
2import database/repositories/lexicons
3import gleam/json
4-import gleam/option
5import gleam/string
6import gleeunit/should
7import lib/mcp/tools/graphql as graphql_tools
···16 // Insert a test lexicon so schema builds
17 let lexicon_json =
18 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}"
19- let assert Ok(_) = lexicons.insert(exec, "test.example.status", lexicon_json)
02021 exec
22}
···1import database/executor.{type Executor}
2import database/repositories/lexicons
3import gleam/json
4+import gleam/option.{None}
5import gleam/string
6import gleeunit/should
7import lib/mcp/tools/graphql as graphql_tools
···16 // Insert a test lexicon so schema builds
17 let lexicon_json =
18 "{\"lexicon\":1,\"id\":\"test.example.status\",\"defs\":{\"main\":{\"type\":\"record\",\"key\":\"tid\",\"record\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}}}}"
19+ let assert Ok(_) =
20+ lexicons.insert(exec, "test.example.status", lexicon_json, None)
2122 exec
23}
+5-2
server/test/mcp/tools/lexicons_test.gleam
···1import database/repositories/lexicons
2import gleam/json
03import gleam/string
4import gleeunit/should
5import lib/mcp/tools/lexicons as lexicon_tools
···13 // Insert a test lexicon
14 let lexicon_json =
15 "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
16- let assert Ok(_) = lexicons.insert(exec, "test.example.record", lexicon_json)
01718 // Call the tool
19 let result = lexicon_tools.list_lexicons(exec)
···34 // Insert a test lexicon
35 let lexicon_json =
36 "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
37- let assert Ok(_) = lexicons.insert(exec, "test.example.record", lexicon_json)
03839 // Call the tool
40 let result = lexicon_tools.get_lexicon(exec, "test.example.record")
···1import database/repositories/lexicons
2import gleam/json
3+import gleam/option.{None}
4import gleam/string
5import gleeunit/should
6import lib/mcp/tools/lexicons as lexicon_tools
···14 // Insert a test lexicon
15 let lexicon_json =
16 "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
17+ let assert Ok(_) =
18+ lexicons.insert(exec, "test.example.record", lexicon_json, None)
1920 // Call the tool
21 let result = lexicon_tools.list_lexicons(exec)
···36 // Insert a test lexicon
37 let lexicon_json =
38 "{\"lexicon\":1,\"id\":\"test.example.record\",\"defs\":{\"main\":{\"type\":\"record\"}}}"
39+ let assert Ok(_) =
40+ lexicons.insert(exec, "test.example.record", lexicon_json, None)
4142 // Call the tool
43 let result = lexicon_tools.get_lexicon(exec, "test.example.record")
···49 "CREATE TABLE IF NOT EXISTS lexicon (
50 id TEXT PRIMARY KEY NOT NULL,
51 json TEXT NOT NULL,
52- created_at TEXT NOT NULL DEFAULT (datetime('now'))
053 )",
54 [],
55 )
···49 "CREATE TABLE IF NOT EXISTS lexicon (
50 id TEXT PRIMARY KEY NOT NULL,
51 json TEXT NOT NULL,
52+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
53+ validator_js TEXT
54 )",
55 [],
56 )