New syscalls for schema-self describing
This commit is contained in:
+279
-222
@@ -1,8 +1,6 @@
|
||||
//! `describe` command — show available query types and tag schemas.
|
||||
//!
|
||||
//! The two embedded Lua scripts are byte-for-byte significant; any whitespace
|
||||
//! change would break the server-side execution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use serde::Deserialize;
|
||||
@@ -11,112 +9,104 @@ use crate::conn::SpaceConnection;
|
||||
use crate::output::{self, OutputMode};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua scripts — whitespace-significant, do not reformat
|
||||
// Lua scripts — call the index.* schema introspection API (single source of
|
||||
// truth in Core); whitespace-significant, do not reformat.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared Lua helper for extracting schema properties from a tag definition.
|
||||
const LUA_EXTRACT_PROPS: &str = "
|
||||
function extractProps(def)
|
||||
local props = {}
|
||||
if def.schema and def.schema.properties then
|
||||
for pname, pdef in pairs(def.schema.properties) do
|
||||
local typ = pdef.type or \"any\"
|
||||
if type(typ) == \"table\" then typ = \"mixed\" end
|
||||
local info = {
|
||||
name = pname,
|
||||
type = typ,
|
||||
readOnly = pdef.readOnly or false,
|
||||
nullable = pdef.nullable or false,
|
||||
}
|
||||
if pdef.enum then info.enum = pdef.enum end
|
||||
table.insert(props, info)
|
||||
end
|
||||
table.sort(props, function(a, b) return a.name < b.name end)
|
||||
end
|
||||
return props
|
||||
end
|
||||
";
|
||||
|
||||
/// Body of the "describe all" script (appended after LUA_EXTRACT_PROPS).
|
||||
const DESCRIBE_ALL_BODY: &str = "
|
||||
local tags = config.get(\"tags\", {})
|
||||
local result = {}
|
||||
for name, def in pairs(tags) do
|
||||
table.insert(result, {
|
||||
name = name,
|
||||
properties = extractProps(def),
|
||||
hasSchema = def.schema ~= nil,
|
||||
})
|
||||
end
|
||||
table.sort(result, function(a, b) return a.name < b.name end)
|
||||
|
||||
/// "describe all": raw JSON Schemas from the API plus the SLIQ reference syntax block.
|
||||
const DESCRIBE_ALL_SCRIPT: &str = "
|
||||
local page = space.readPage(\"Library/Std/Docs/SLIQ Reference\")
|
||||
local parsed = index.extractFrontmatter(page, {removeFrontMatterSection = true})
|
||||
|
||||
return { tags = result, syntax = parsed.text }
|
||||
return { schemas = index.describeSchema(), syntax = parsed.text }
|
||||
";
|
||||
|
||||
/// Body of the "describe tag" script (appended after LUA_EXTRACT_PROPS).
|
||||
/// Contains exactly one `%s` placeholder for the tag name.
|
||||
/// "describe tag": one tag's raw JSON Schema (or nil). Exactly one `%s` for the tag name.
|
||||
const DESCRIBE_TAG_BODY: &str = "
|
||||
local tagName = \"%s\"
|
||||
local tags = config.get(\"tags\", {})
|
||||
local def = tags[tagName]
|
||||
if not def then
|
||||
error(\"Unknown tag: \" .. tagName)
|
||||
end
|
||||
return {
|
||||
name = tagName,
|
||||
properties = extractProps(def),
|
||||
hasSchema = def.schema ~= nil,
|
||||
additionalProperties = def.schema and def.schema.additionalProperties or false,
|
||||
}
|
||||
return index.tagSchema(\"%s\")
|
||||
";
|
||||
|
||||
fn describe_all_script() -> String {
|
||||
format!("{LUA_EXTRACT_PROPS}{DESCRIBE_ALL_BODY}")
|
||||
DESCRIBE_ALL_SCRIPT.to_string()
|
||||
}
|
||||
|
||||
fn describe_tag_script(tag: &str) -> String {
|
||||
format!("{LUA_EXTRACT_PROPS}{DESCRIBE_TAG_BODY}").replacen("%s", tag, 1)
|
||||
DESCRIBE_TAG_BODY.replacen("%s", tag, 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed shapes matching the Lua return values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TagProperty {
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
#[serde(default, rename = "readOnly")]
|
||||
read_only: bool,
|
||||
#[serde(default)]
|
||||
nullable: bool,
|
||||
#[serde(default, rename = "enum")]
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct TagInfo {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<TagProperty>,
|
||||
#[serde(default, rename = "hasSchema")]
|
||||
has_schema: bool,
|
||||
#[serde(default, rename = "additionalProperties")]
|
||||
additional_properties: bool,
|
||||
}
|
||||
|
||||
/// Deserialized result of `describe all`: a map of tag name → JSON Schema plus
|
||||
/// the SLIQ reference syntax text.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DescribeAllResult {
|
||||
#[serde(default)]
|
||||
tags: Vec<TagInfo>,
|
||||
schemas: HashMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
syntax: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal property struct for text rendering (not from Lua)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single property extracted from a JSON Schema `properties` object.
|
||||
#[derive(Debug)]
|
||||
struct TagProperty {
|
||||
name: String,
|
||||
type_: String,
|
||||
read_only: bool,
|
||||
nullable: bool,
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presentation-layer extraction: JSON Schema → Vec<TagProperty>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Walk a JSON Schema object and extract its `properties` into a sorted Vec of
|
||||
/// `TagProperty`. This is the flattening logic that used to live in
|
||||
/// `schema_introspection.ts`.
|
||||
fn extract_properties_from_schema(schema: &serde_json::Value) -> Vec<TagProperty> {
|
||||
let mut props = Vec::new();
|
||||
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (name, pdef) in properties {
|
||||
let type_str = match pdef.get("type") {
|
||||
Some(serde_json::Value::String(t)) => t.clone(),
|
||||
Some(_) => "mixed".to_string(),
|
||||
None => "any".to_string(),
|
||||
};
|
||||
let read_only = pdef
|
||||
.get("readOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let nullable = pdef
|
||||
.get("nullable")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let enum_values: Vec<String> = pdef
|
||||
.get("enum")
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
props.push(TagProperty {
|
||||
name: name.clone(),
|
||||
type_: type_str,
|
||||
read_only,
|
||||
nullable,
|
||||
enum_values,
|
||||
});
|
||||
}
|
||||
props.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag name validation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -176,9 +166,14 @@ fn describe_all(
|
||||
writeln!(out).map_err(|e| e.to_string())?;
|
||||
writeln!(out, "Available object types:").map_err(|e| e.to_string())?;
|
||||
|
||||
for tag in &result.tags {
|
||||
let props = summarize_props(tag);
|
||||
writeln!(out, " {:<18} {}", tag.name, props).map_err(|e| e.to_string())?;
|
||||
let mut tag_names: Vec<&String> = result.schemas.keys().collect();
|
||||
tag_names.sort();
|
||||
|
||||
for tag_name in &tag_names {
|
||||
let schema = &result.schemas[*tag_name];
|
||||
let props = extract_properties_from_schema(schema);
|
||||
let summary = summarize_props(&props);
|
||||
writeln!(out, " {:<18} {}", tag_name, summary).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
writeln!(out).map_err(|e| e.to_string())?;
|
||||
@@ -209,15 +204,22 @@ fn describe_tag(
|
||||
|
||||
let raw = conn.eval_lua_script(&describe_tag_script(tag))?;
|
||||
|
||||
if raw.is_null() {
|
||||
return Err(format!("Unknown tag: {tag}"));
|
||||
}
|
||||
|
||||
if mode == OutputMode::Json {
|
||||
return output::format(out, &raw, OutputMode::Json).map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
let tag_info: TagInfo =
|
||||
serde_json::from_value(raw).map_err(|e| format!("parsing tag info: {e}"))?;
|
||||
// `raw` is a raw JSON Schema value.
|
||||
let additional_properties = raw
|
||||
.get("additionalProperties")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
writeln!(out, "Type: {}", tag_info.name).map_err(|e| e.to_string())?;
|
||||
if tag_info.additional_properties {
|
||||
writeln!(out, "Type: {}", tag).map_err(|e| e.to_string())?;
|
||||
if additional_properties {
|
||||
writeln!(
|
||||
out,
|
||||
"Accepts additional properties (e.g. frontmatter fields)"
|
||||
@@ -226,13 +228,15 @@ fn describe_tag(
|
||||
}
|
||||
writeln!(out).map_err(|e| e.to_string())?;
|
||||
|
||||
if tag_info.properties.is_empty() {
|
||||
let props = extract_properties_from_schema(&raw);
|
||||
|
||||
if props.is_empty() {
|
||||
writeln!(out, "No schema defined.").map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(out, "Properties:").map_err(|e| e.to_string())?;
|
||||
for prop in &tag_info.properties {
|
||||
for prop in &props {
|
||||
let mut flags: Vec<String> = Vec::new();
|
||||
if prop.read_only {
|
||||
flags.push("read-only".to_string());
|
||||
@@ -259,14 +263,11 @@ fn describe_tag(
|
||||
// summarize_props — short summary for the "describe all" listing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn summarize_props(tag: &TagInfo) -> String {
|
||||
if tag.properties.is_empty() {
|
||||
if !tag.has_schema {
|
||||
return "(no schema)".to_string();
|
||||
}
|
||||
fn summarize_props(props: &[TagProperty]) -> String {
|
||||
if props.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let names: Vec<&str> = tag.properties.iter().map(|p| p.name.as_str()).collect();
|
||||
let names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
|
||||
if names.len() > 5 {
|
||||
format!("{}, ...", names[..5].join(", "))
|
||||
} else {
|
||||
@@ -317,20 +318,13 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn describe_tag_script_substitution() {
|
||||
fn describe_tag_script_calls_api() {
|
||||
let script = describe_tag_script("task");
|
||||
assert!(
|
||||
script.contains("local tagName = \"task\""),
|
||||
"should contain substituted tag name"
|
||||
);
|
||||
assert!(
|
||||
script.contains("function extractProps(def)"),
|
||||
"should contain extractProps function"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("%s"),
|
||||
"should have no remaining %s placeholder"
|
||||
script.contains("index.tagSchema(\"task\")"),
|
||||
"should call index.tagSchema with the substituted tag name"
|
||||
);
|
||||
assert!(!script.contains("%s"), "no remaining %s placeholder");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -338,28 +332,91 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn describe_all_script_contents() {
|
||||
fn describe_all_script_calls_api() {
|
||||
let script = describe_all_script();
|
||||
assert!(
|
||||
script.starts_with("\nfunction extractProps(def)"),
|
||||
"should start with extractProps function"
|
||||
script.contains("index.describeSchema()"),
|
||||
"should call index.describeSchema()"
|
||||
);
|
||||
assert!(
|
||||
script.contains("config.get(\"tags\", {})"),
|
||||
"should contain config.get tags call"
|
||||
);
|
||||
assert!(
|
||||
script.contains("return { tags = result, syntax = parsed.text }"),
|
||||
"should contain correct return statement"
|
||||
script.contains("return { schemas = index.describeSchema(), syntax = parsed.text }"),
|
||||
"should return schemas + syntax"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extract_properties_from_schema
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_properties_sorts_by_name() {
|
||||
let schema: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"z_prop": { "type": "string" },
|
||||
"a_prop": { "type": "boolean" },
|
||||
"m_prop": { "type": "number" }
|
||||
}
|
||||
});
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
let names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["a_prop", "m_prop", "z_prop"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_properties_handles_readonly_nullable_enum() {
|
||||
let schema: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"perm": { "type": "string", "readOnly": true, "enum": ["ro", "rw"] },
|
||||
"itags": { "type": "array", "nullable": true }
|
||||
}
|
||||
});
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
let perm = props.iter().find(|p| p.name == "perm").unwrap();
|
||||
assert!(perm.read_only);
|
||||
assert_eq!(perm.enum_values, vec!["ro", "rw"]);
|
||||
let itags = props.iter().find(|p| p.name == "itags").unwrap();
|
||||
assert!(itags.nullable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_properties_missing_type_is_any() {
|
||||
let schema: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deadline": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
|
||||
}
|
||||
});
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
assert_eq!(props[0].type_, "any");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_properties_non_string_type_is_mixed() {
|
||||
let schema: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"complex": { "type": ["string", "null"] }
|
||||
}
|
||||
});
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
assert_eq!(props[0].type_, "mixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_properties_empty_schema_returns_empty() {
|
||||
let schema: Value = serde_json::json!({ "type": "object" });
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
assert!(props.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// summarize_props
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_tag_with_props(names: &[&str], has_schema: bool) -> TagInfo {
|
||||
let properties = names
|
||||
fn make_props(names: &[&str]) -> Vec<TagProperty> {
|
||||
names
|
||||
.iter()
|
||||
.map(|n| TagProperty {
|
||||
name: n.to_string(),
|
||||
@@ -368,64 +425,51 @@ mod tests {
|
||||
nullable: false,
|
||||
enum_values: vec![],
|
||||
})
|
||||
.collect();
|
||||
TagInfo {
|
||||
name: "test".to_string(),
|
||||
properties,
|
||||
has_schema,
|
||||
additional_properties: false,
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_empty_no_schema() {
|
||||
let tag = make_tag_with_props(&[], false);
|
||||
assert_eq!(summarize_props(&tag), "(no schema)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_empty_has_schema() {
|
||||
let tag = make_tag_with_props(&[], true);
|
||||
assert_eq!(summarize_props(&tag), "");
|
||||
fn summarize_empty_returns_empty_string() {
|
||||
assert_eq!(summarize_props(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_three_props() {
|
||||
let tag = make_tag_with_props(&["a", "b", "c"], true);
|
||||
assert_eq!(summarize_props(&tag), "a, b, c");
|
||||
let props = make_props(&["a", "b", "c"]);
|
||||
assert_eq!(summarize_props(&props), "a, b, c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_seven_props_truncates_to_five() {
|
||||
let tag = make_tag_with_props(&["a", "b", "c", "d", "e", "f", "g"], true);
|
||||
let result = summarize_props(&tag);
|
||||
let props = make_props(&["a", "b", "c", "d", "e", "f", "g"]);
|
||||
let result = summarize_props(&props);
|
||||
assert_eq!(result, "a, b, c, d, e, ...");
|
||||
// Verify it ends with ", ..."
|
||||
assert!(result.ends_with(", ..."));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// describe_all text rendering
|
||||
// describe_all text rendering (new schema-map shape)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn describe_all_text_renders_header_and_tags() {
|
||||
let raw: Value = serde_json::json!({
|
||||
"tags": [
|
||||
{
|
||||
"name": "task",
|
||||
"properties": [
|
||||
{"name": "done", "type": "boolean", "readOnly": false, "nullable": false},
|
||||
{"name": "due", "type": "string", "readOnly": false, "nullable": false},
|
||||
],
|
||||
"hasSchema": true
|
||||
"schemas": {
|
||||
"task": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"done": { "type": "boolean" },
|
||||
"due": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"properties": [],
|
||||
"hasSchema": false
|
||||
"page": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"syntax": ""
|
||||
});
|
||||
|
||||
@@ -436,9 +480,13 @@ mod tests {
|
||||
writeln!(buf, "SilverBullet Query Reference").unwrap();
|
||||
writeln!(buf).unwrap();
|
||||
writeln!(buf, "Available object types:").unwrap();
|
||||
for tag in &result.tags {
|
||||
let props = summarize_props(tag);
|
||||
writeln!(buf, " {:<18} {}", tag.name, props).unwrap();
|
||||
let mut tag_names: Vec<&String> = result.schemas.keys().collect();
|
||||
tag_names.sort();
|
||||
for tag_name in &tag_names {
|
||||
let schema = &result.schemas[*tag_name];
|
||||
let props = extract_properties_from_schema(schema);
|
||||
let summary = summarize_props(&props);
|
||||
writeln!(buf, " {:<18} {}", tag_name, summary).unwrap();
|
||||
}
|
||||
writeln!(buf).unwrap();
|
||||
writeln!(buf, "Run 'sb describe <type>' for full schema.").unwrap();
|
||||
@@ -449,50 +497,49 @@ mod tests {
|
||||
assert!(out.contains("task"));
|
||||
assert!(out.contains("done, due"));
|
||||
assert!(out.contains("page"));
|
||||
assert!(out.contains("(no schema)"));
|
||||
assert!(out.contains("name"));
|
||||
assert!(out.contains("Run 'sb describe <type>' for full schema."));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// describe_tag text rendering
|
||||
// describe_tag text rendering (new raw-schema shape)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn describe_tag_text_renders_properties() {
|
||||
let tag_info = TagInfo {
|
||||
name: "task".to_string(),
|
||||
properties: vec![
|
||||
TagProperty {
|
||||
name: "done".to_string(),
|
||||
type_: "boolean".to_string(),
|
||||
read_only: false,
|
||||
nullable: false,
|
||||
enum_values: vec![],
|
||||
},
|
||||
TagProperty {
|
||||
name: "priority".to_string(),
|
||||
type_: "number".to_string(),
|
||||
read_only: true,
|
||||
nullable: false,
|
||||
enum_values: vec![],
|
||||
},
|
||||
TagProperty {
|
||||
name: "status".to_string(),
|
||||
type_: "string".to_string(),
|
||||
read_only: false,
|
||||
nullable: true,
|
||||
enum_values: vec!["open".to_string(), "closed".to_string()],
|
||||
},
|
||||
],
|
||||
has_schema: true,
|
||||
additional_properties: false,
|
||||
};
|
||||
let schema: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"done": { "type": "boolean", "readOnly": false, "nullable": false },
|
||||
"priority": { "type": "number", "readOnly": true, "nullable": false },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"readOnly": false,
|
||||
"nullable": true,
|
||||
"enum": ["open", "closed"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let additional_properties = schema
|
||||
.get("additionalProperties")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let props = extract_properties_from_schema(&schema);
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
writeln!(buf, "Type: {}", tag_info.name).unwrap();
|
||||
writeln!(buf, "Type: task").unwrap();
|
||||
if additional_properties {
|
||||
writeln!(
|
||||
buf,
|
||||
"Accepts additional properties (e.g. frontmatter fields)"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(buf).unwrap();
|
||||
writeln!(buf, "Properties:").unwrap();
|
||||
for prop in &tag_info.properties {
|
||||
for prop in &props {
|
||||
let mut flags: Vec<String> = Vec::new();
|
||||
if prop.read_only {
|
||||
flags.push("read-only".to_string());
|
||||
@@ -523,43 +570,53 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lua script byte-exactness spot checks
|
||||
// Deserialization contract for the new API shape
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn lua_extract_props_exact_content() {
|
||||
// Spot-check that key Lua lines are present byte-exactly.
|
||||
assert!(LUA_EXTRACT_PROPS.contains(" local props = {}"));
|
||||
assert!(LUA_EXTRACT_PROPS.contains(" local typ = pdef.type or \"any\""));
|
||||
assert!(
|
||||
LUA_EXTRACT_PROPS.contains(" if type(typ) == \"table\" then typ = \"mixed\" end")
|
||||
);
|
||||
assert!(LUA_EXTRACT_PROPS.contains(" if pdef.enum then info.enum = pdef.enum end"));
|
||||
assert!(LUA_EXTRACT_PROPS
|
||||
.contains(" table.sort(props, function(a, b) return a.name < b.name end)"));
|
||||
assert!(LUA_EXTRACT_PROPS.contains(" return props"));
|
||||
fn deserializes_describe_schema_payload_as_schema_map() {
|
||||
// Shape returned by `return { schemas = index.describeSchema(), syntax = "..." }`
|
||||
let raw: Value = serde_json::json!({
|
||||
"schemas": {
|
||||
"task": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"done": { "type": "boolean", "readOnly": true },
|
||||
"state": { "type": "string", "readOnly": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"syntax": ""
|
||||
});
|
||||
let result: DescribeAllResult = serde_json::from_value(raw).unwrap();
|
||||
assert!(result.schemas.contains_key("task"));
|
||||
let task_schema = &result.schemas["task"];
|
||||
let props = extract_properties_from_schema(task_schema);
|
||||
let done = props
|
||||
.iter()
|
||||
.find(|p| p.name == "done")
|
||||
.expect("task has a 'done' property");
|
||||
assert_eq!(done.type_, "boolean");
|
||||
assert!(done.read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_all_body_exact_content() {
|
||||
assert!(DESCRIBE_ALL_BODY.contains("local tags = config.get(\"tags\", {})"));
|
||||
assert!(DESCRIBE_ALL_BODY.contains(" hasSchema = def.schema ~= nil,"));
|
||||
assert!(DESCRIBE_ALL_BODY
|
||||
.contains("table.sort(result, function(a, b) return a.name < b.name end)"));
|
||||
assert!(DESCRIBE_ALL_BODY
|
||||
.contains("local page = space.readPage(\"Library/Std/Docs/SLIQ Reference\")"));
|
||||
assert!(DESCRIBE_ALL_BODY.contains(
|
||||
"local parsed = index.extractFrontmatter(page, {removeFrontMatterSection = true})"
|
||||
));
|
||||
assert!(DESCRIBE_ALL_BODY.contains("return { tags = result, syntax = parsed.text }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_tag_body_exact_content() {
|
||||
assert!(DESCRIBE_TAG_BODY.contains("local tagName = \"%s\""));
|
||||
assert!(DESCRIBE_TAG_BODY.contains(" error(\"Unknown tag: \" .. tagName)"));
|
||||
assert!(DESCRIBE_TAG_BODY.contains(
|
||||
" additionalProperties = def.schema and def.schema.additionalProperties or false,"
|
||||
));
|
||||
fn deserializes_tag_schema_as_raw_json_value() {
|
||||
// Shape returned by `return index.tagSchema("task")`
|
||||
let raw: Value = serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"done": { "type": "boolean", "readOnly": true, "nullable": false }
|
||||
}
|
||||
});
|
||||
// Not null, so we proceed to render
|
||||
assert!(!raw.is_null());
|
||||
let props = extract_properties_from_schema(&raw);
|
||||
assert_eq!(props.len(), 1);
|
||||
assert_eq!(props[0].name, "done");
|
||||
assert_eq!(props[0].type_, "boolean");
|
||||
assert!(props[0].read_only);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
|
||||
import type { Client } from "../../client.ts";
|
||||
import type { LuaTable } from "../../space_lua/runtime.ts";
|
||||
import { describeSchemas, tagSchema } from "./schema_introspection.ts";
|
||||
|
||||
export function indexSyscalls(
|
||||
objectIndex: ObjectIndex,
|
||||
@@ -64,6 +65,13 @@ export function indexSyscalls(
|
||||
"index.tags": (): LuaQueryCollection => {
|
||||
return objectIndex.objectsWithTag("tag");
|
||||
},
|
||||
// Schema introspection: indexed object-type / tag schemas
|
||||
"index.describeSchema": (): Record<string, unknown> => {
|
||||
return describeSchemas(client.config.get(["tags"], {}));
|
||||
},
|
||||
"index.tagSchema": (_ctx, tagName: string): unknown | null => {
|
||||
return tagSchema(client.config.get(["tags"], {}), tagName);
|
||||
},
|
||||
"index.aspiringPages": (): LuaQueryCollection => {
|
||||
return objectIndex.aspiringPages();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { describeSchemas, tagSchema } from "./schema_introspection.ts";
|
||||
import { indexSyscalls } from "./index.ts";
|
||||
|
||||
// Fixtures mirroring real config under ["tags"] (plain JS, as config.get returns).
|
||||
const tags = {
|
||||
task: {
|
||||
name: "task",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
done: { type: "boolean", readOnly: true },
|
||||
state: { type: "string", readOnly: true },
|
||||
name: { type: "string", readOnly: true },
|
||||
// schema.array("string") shape -> type "array"
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
// schema.nullable("string") shape -> anyOf with no top-level type
|
||||
deadline: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
page: {
|
||||
name: "page",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
// enum + readOnly
|
||||
perm: { type: "string", readOnly: true, enum: ["ro", "rw"] },
|
||||
name: { type: "string" },
|
||||
itags: { type: "array", items: { type: "string" }, nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
// Custom tag.define'd tag (e.g. from CONFIG.md)
|
||||
person: {
|
||||
name: "person",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
age: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// A tag with no schema at all (must be omitted from describeSchemas)
|
||||
bareTag: {
|
||||
name: "bareTag",
|
||||
},
|
||||
};
|
||||
|
||||
test("describeSchemas returns only tags with schemas, keyed by tag name", () => {
|
||||
const result = describeSchemas(tags);
|
||||
// bareTag has no schema and must be omitted
|
||||
expect(Object.keys(result).sort()).toEqual(["page", "person", "task"]);
|
||||
});
|
||||
|
||||
test("describeSchemas returns the unmodified raw JSON Schema for each tag", () => {
|
||||
const result = describeSchemas(tags);
|
||||
expect((result.task as any).properties.done.type).toBe("boolean");
|
||||
expect((result.task as any).additionalProperties).toBe(true);
|
||||
// anyOf shape is preserved as-is (not flattened)
|
||||
expect((result.task as any).properties.deadline.anyOf).toBeDefined();
|
||||
expect((result.page as any).properties.perm.enum).toEqual(["ro", "rw"]);
|
||||
expect((result.person as any).properties.age.type).toBe("number");
|
||||
});
|
||||
|
||||
test("tagSchema returns the raw JSON Schema for a defined tag with a schema", () => {
|
||||
const schema = tagSchema(tags, "task") as any;
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.properties.done.type).toBe("boolean");
|
||||
expect(schema.properties.done.readOnly).toBe(true);
|
||||
// anyOf shape preserved intact
|
||||
expect(schema.properties.deadline.anyOf).toBeDefined();
|
||||
// array type preserved intact
|
||||
expect(schema.properties.tags.type).toBe("array");
|
||||
expect(schema.properties.tags.items.type).toBe("string");
|
||||
});
|
||||
|
||||
test("tagSchema returns null for a tag without a schema", () => {
|
||||
expect(tagSchema(tags, "bareTag")).toBeNull();
|
||||
});
|
||||
|
||||
test("tagSchema returns null for an undefined tag", () => {
|
||||
expect(tagSchema(tags, "doesNotExist")).toBeNull();
|
||||
});
|
||||
|
||||
test("index.describeSchema / index.tagSchema syscalls delegate to config", () => {
|
||||
const fakeClient: any = {
|
||||
config: { get: (_path: string[], def: any) => tags ?? def },
|
||||
};
|
||||
const syscalls = indexSyscalls({} as any, fakeClient);
|
||||
const all = (syscalls["index.describeSchema"] as any)({}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// Only tags with schemas are returned (bareTag omitted)
|
||||
expect(Object.keys(all).sort()).toEqual(["page", "person", "task"]);
|
||||
// Values are raw schema objects
|
||||
expect((all.task as any).properties.done.type).toBe("boolean");
|
||||
|
||||
const taskSchema = (syscalls["index.tagSchema"] as any)({}, "task") as any;
|
||||
expect(taskSchema).not.toBeNull();
|
||||
expect(taskSchema.properties.done.type).toBe("boolean");
|
||||
|
||||
// undefined tag → null
|
||||
expect((syscalls["index.tagSchema"] as any)({}, "nope")).toBeNull();
|
||||
// tag without schema → null
|
||||
expect((syscalls["index.tagSchema"] as any)({}, "bareTag")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Pure schema-introspection helpers over the `["tags"]` config table.
|
||||
*
|
||||
* Returns raw JSON Schema objects; presentation-layer flattening (into typed
|
||||
* property rows) happens in the `sb describe` CLI (describe.rs).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns a map of tag name → raw JSON Schema for every tag that defines a schema.
|
||||
* Tags without a schema are omitted.
|
||||
*/
|
||||
export function describeSchemas(
|
||||
tags: Record<string, any>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [name, def] of Object.entries(tags)) {
|
||||
if (def?.schema != null) {
|
||||
result[name] = def.schema;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw JSON Schema for a single tag, or null if the tag is
|
||||
* undefined or has no schema.
|
||||
*/
|
||||
export function tagSchema(
|
||||
tags: Record<string, any>,
|
||||
tagName: string,
|
||||
): unknown | null {
|
||||
return tags[tagName]?.schema ?? null;
|
||||
}
|
||||
@@ -87,6 +87,12 @@ export function systemSyscalls(
|
||||
}
|
||||
return client.loadPlugs();
|
||||
},
|
||||
"system.reboot": async () => {
|
||||
await client.save(true);
|
||||
await client.eventedSpacePrimitives.fetchFileListWhenIdle();
|
||||
await client.mq.awaitEmptyQueue("indexQueue");
|
||||
await client.clientSystem.reloadState();
|
||||
},
|
||||
"system.loadPlug": async (_ctx, path: string) => {
|
||||
const meta = await client.space.spacePrimitives.getFileMeta(path);
|
||||
await client.clientSystem.loadPlugFromPath(path, meta.lastModified);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EventHook } from "../plugos/hooks/event.ts";
|
||||
import type { SpacePrimitives } from "./space_primitives.ts";
|
||||
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
|
||||
import type { DataStore } from "../data/datastore.ts";
|
||||
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
|
||||
|
||||
/**
|
||||
* Events exposed:
|
||||
@@ -151,6 +152,22 @@ export class EventedSpacePrimitives implements SpacePrimitives {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like fetchFileList(), but waits for any in-flight space operations to
|
||||
* settle first so that the snapshot-comparison path is always taken (never
|
||||
* the deferred early-return path). Use this when you need to guarantee that
|
||||
* file:changed events are dispatched for every on-disk change — for example
|
||||
* during a controlled "reboot to ready" sequence where another fetchFileList
|
||||
* may be in progress (e.g. the one kicked off at the end of
|
||||
* updatePageListCache()).
|
||||
*/
|
||||
async fetchFileListWhenIdle(): Promise<FileMeta[]> {
|
||||
while (this.operationCount > 0) {
|
||||
await sleep(10);
|
||||
}
|
||||
return this.fetchFileList();
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
||||
if (!this.enabled) {
|
||||
return this.wrapped.readFile(path);
|
||||
|
||||
@@ -97,6 +97,13 @@ Returns all [[Object/tag]] objects as a query collection.
|
||||
Example:
|
||||
${query[[from index.tags() limit 3]]}
|
||||
|
||||
# Schema introspection APIs
|
||||
## index.describeSchema()
|
||||
Returns a map of tag name → raw JSON Schema for every defined object type / [[Tag]] that declares a schema. Tags without a schema are omitted. Use it to discover what attributes a tag's objects carry before querying them.
|
||||
|
||||
## index.tagSchema(tag)
|
||||
Returns the raw JSON Schema for a single tag, or `nil` if the tag is not defined or has no schema.
|
||||
|
||||
# Indexing APIs
|
||||
## index.markdown(text, pageMeta?)
|
||||
Ad-hoc indexes `text` (represented as a markdown string) in memory, and returns all objects found there for further query. When no `pageMeta` is supplied dummy (empty) values will be used.
|
||||
|
||||
@@ -90,6 +90,12 @@ system.reloadPlugs()
|
||||
print("All plugs reloaded")
|
||||
```
|
||||
|
||||
### system.reboot()
|
||||
Makes edited-on-disk state live and resolves only once the client is ready again. Useful for scripts, the `sb` CLI, and external tooling that change space files on disk and need a single "reboot to ready" call.
|
||||
|
||||
It mirrors the **System: Reload** command: it saves the currently-open editor buffer first, then flushes any latent on-disk changes into the index queue (via snapshot detection — not a full reindex), waits for indexing to finish, and finally re-applies configuration, scripts, and styles. Because the buffer is saved first, a raw on-disk edit to the *currently-open* page can be overwritten by the in-memory buffer; edit the open page through the editor (or navigate away) rather than on disk if that matters.
|
||||
|
||||
|
||||
### system.wipeClient(logout?)
|
||||
Completely wipes the client state, including cached files, service worker and databases.
|
||||
|
||||
|
||||
+6
-1
@@ -3,6 +3,8 @@ An attempt at documenting the changes/new features introduced in each release.
|
||||
## Edge
|
||||
Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be released as a docker image with the `:v2` tag, and a binary in the [edge release](https://github.com/silverbulletmd/silverbullet/releases/tag/edge). If you want to live on the bleeding edge of SilverBullet goodness (or regression) this is where to do it.
|
||||
|
||||
* New `index.describeSchema()` and `index.tagSchema(tag)` Space Lua APIs that expose indexed object-type / tag schemas as raw JSON Schema to scripts, widgets, and the `sb describe` CLI: `describeSchema()` returns a map of tag name → JSON Schema (only tags that declare a schema), and `tagSchema(tag)` returns a tag's JSON Schema or `nil` if undefined or schema-less.
|
||||
* New `system.reboot()` Space Lua syscall: makes edited-on-disk changes live.
|
||||
*-* **Baked sections**: bake `${...}` Lua expressions and widgets into
|
||||
HTML-comment-delimited markdown (`<!--#lua EXPR -->` … `<!--/lua-->`) so
|
||||
power-feature pages render in GitHub and other markdown tools. A block `${…}`
|
||||
@@ -17,10 +19,13 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be
|
||||
preview behavior: raw YAML syntax stays visible when markdown syntax
|
||||
rendering is enabled, and only the link currently being edited is revealed in
|
||||
clean mode.
|
||||
* Fix: tags shown in folded frontmatter now navigate to their tag pages instead
|
||||
of unfolding the frontmatter block.
|
||||
* Frontmatter in the editor now has configurable folding: by default long
|
||||
frontmatter blocks fold automatically, and `frontmatterFolding` options let
|
||||
you disable auto-folding, always fold frontmatter, or change the line
|
||||
threshold. A subtle right-side marker folds or unfolds the whole block.
|
||||
threshold. A subtle right-side marker folds or unfolds the whole block, and
|
||||
folded frontmatter previews any `tags` value as tag chips.
|
||||
* Fix: write-mode commands (those requiring read-write, e.g. the baking
|
||||
commands) are now hidden in the command palette and their keybindings disabled
|
||||
on **per-page** read-only pages (`perm: ro`), not just in fully read-only
|
||||
|
||||
@@ -10,7 +10,7 @@ references:
|
||||
- plugs/index/page.ts
|
||||
---
|
||||
Page decorations allow you to “decorate” pages in various fun ways.
|
||||
|
||||
|
||||
# Supported decorations
|
||||
* `prefix`: A (visual) string prefix (often an emoji) to add to all page names. This prefix will appear in the top bar as well as in (live preview) links to this page. For example, the name of this page is actually “Page Decorations”, but when you link to it, you’ll see it’s prefixed with a 🎄: [[Page Decorations]]
|
||||
* `cssClasses`: (list of strings) Attaches one or more CSS classes the page's `<body>` tag, wiki links, auto complete items and [[Page Picker]] entries for more advanced styling through a [[Space Style]] (see [[#Use case: pimp my page]] for an example).
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, gotoSilverBulletPage, test } from "./fixtures.ts";
|
||||
|
||||
/** Run a Space Lua script in the live in-page runtime (headless mode). */
|
||||
async function runLua(page: Page, script: string): Promise<unknown> {
|
||||
return await page.evaluate(
|
||||
(s) => (globalThis as any).sbRuntime.evalLuaScript(s),
|
||||
script,
|
||||
);
|
||||
}
|
||||
|
||||
/** Invoke system.reboot() via the in-page runtime. Resolves when ready. */
|
||||
async function reboot(page: Page): Promise<void> {
|
||||
await runLua(page, "system.reboot()");
|
||||
}
|
||||
|
||||
/** Run a SLIQ expression and return the resulting array. */
|
||||
async function query(page: Page, sliq: string): Promise<unknown[]> {
|
||||
const result = await runLua(page, `return query[[${sliq}]]`);
|
||||
if (result === null || result === undefined) return [];
|
||||
if (Array.isArray(result)) return result;
|
||||
// An empty Lua table has length 0, so luaValueToJS serializes it as {} not
|
||||
// []. Treat any empty plain object as an empty result set.
|
||||
if (
|
||||
typeof result === "object" &&
|
||||
Object.keys(result as object).length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return result as unknown[];
|
||||
}
|
||||
|
||||
/** Capture all console messages the page emits (live-updating array). */
|
||||
function captureConsole(page: Page): {
|
||||
messages: { type: string; text: string }[];
|
||||
} {
|
||||
const messages: { type: string; text: string }[] = [];
|
||||
page.on("console", (msg) => messages.push({ type: msg.type(), text: msg.text() }));
|
||||
return { messages };
|
||||
}
|
||||
|
||||
/** Poll until `predicate()` is true or the timeout elapses. */
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(`Condition not met within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
test.describe("system.reboot()", () => {
|
||||
test.use({ spaceFiles: { "index.md": "# Index\nEntry point.\n" } });
|
||||
|
||||
test("out-of-band create: a new on-disk page is indexed after reboot", async ({ sbServer, page }) => {
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
// The page does not exist yet.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "OutOfBand" select name`),
|
||||
).toEqual([]);
|
||||
|
||||
// Create a markdown file directly on disk — no editor, no index event.
|
||||
await writeFile(
|
||||
join(sbServer.spaceDir, "OutOfBand.md"),
|
||||
"# Out Of Band\nCreated directly on disk.\n",
|
||||
);
|
||||
|
||||
await reboot(page);
|
||||
|
||||
// No sleep: the change must already be indexed.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "OutOfBand" select name`),
|
||||
).toEqual(["OutOfBand"]);
|
||||
});
|
||||
|
||||
test("out-of-band modify: index reflects new on-disk content after reboot", async ({ sbServer, page }) => {
|
||||
const notePath = join(sbServer.spaceDir, "Note.md");
|
||||
await writeFile(notePath, "# Note\noriginal content\n");
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
// Original is indexed; the new tag is not present yet.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Note" select name`),
|
||||
).toEqual(["Note"]);
|
||||
expect(await query(page, `from index.tag "rebootmodify" select name`)).toEqual([]);
|
||||
|
||||
// Rewrite the file on disk, adding a frontmatter tag.
|
||||
await writeFile(
|
||||
notePath,
|
||||
"---\ntags: rebootmodify\n---\n# Note\nupdated content\n",
|
||||
);
|
||||
await reboot(page);
|
||||
|
||||
// The page is now tagged from its updated on-disk content.
|
||||
expect(await query(page, `from index.tag "rebootmodify" select name`)).toEqual(["Note"]);
|
||||
});
|
||||
|
||||
test("out-of-band delete: removed file's objects are gone after reboot", async ({ sbServer, page }) => {
|
||||
const tempPath = join(sbServer.spaceDir, "Temp.md");
|
||||
await writeFile(tempPath, "# Temp\ntemporary page\n");
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Temp" select name`),
|
||||
).toEqual(["Temp"]);
|
||||
|
||||
// Remove the file directly on disk.
|
||||
await rm(tempPath);
|
||||
await reboot(page);
|
||||
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Temp" select name`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("drain-before-return: query immediately after reboot sees the change (no sleep)", async ({ sbServer, page }) => {
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
await writeFile(join(sbServer.spaceDir, "Racy.md"), "# Racy\nrace check\n");
|
||||
|
||||
// Before reboot the index must NOT yet know about the new file —
|
||||
// proving the post-reboot result is caused by reboot, not by the
|
||||
// background fetchFileList interval. (Queried well within the 10s
|
||||
// interval to keep this deterministic.)
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Racy" select name`),
|
||||
).toEqual([]);
|
||||
|
||||
await reboot(page);
|
||||
|
||||
// No sleep between reboot resolving and this query: awaitEmptyQueue
|
||||
// must have blocked until indexing finished.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Racy" select name`),
|
||||
).toEqual(["Racy"]);
|
||||
});
|
||||
|
||||
test("script edits applied: a new Library Space Lua function is live after reboot", async ({ sbServer, page }) => {
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
// Function is not defined yet.
|
||||
expect(await runLua(page, "return type(myRebootProbe)")).toBe("nil");
|
||||
|
||||
// Add a Library space-lua script directly on disk.
|
||||
await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true });
|
||||
await writeFile(
|
||||
join(sbServer.spaceDir, "Library", "Probe.md"),
|
||||
'```space-lua\nfunction myRebootProbe()\n return "probe-ok"\nend\n```\n',
|
||||
);
|
||||
|
||||
await reboot(page);
|
||||
|
||||
// The function is indexed and applied to the live Space Lua env.
|
||||
expect(await runLua(page, "return myRebootProbe()")).toBe("probe-ok");
|
||||
});
|
||||
|
||||
test("error surfacing: a Lua load error appears in the console after reboot", async ({ sbServer, page }) => {
|
||||
const consoleState = captureConsole(page);
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
// Add a Library space-lua script with a syntax error.
|
||||
await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true });
|
||||
await writeFile(
|
||||
join(sbServer.spaceDir, "Library", "Broken.md"),
|
||||
"```space-lua\nfunction broken( this is not valid lua\n```\n",
|
||||
);
|
||||
|
||||
// reboot itself resolves cleanly — the failure is only in the logs.
|
||||
await reboot(page);
|
||||
|
||||
await waitFor(() =>
|
||||
consoleState.messages.some(
|
||||
(m) =>
|
||||
m.type === "error" &&
|
||||
/Error (loading|evaluating|reloading) (Lua )?script/i.test(m.text),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("no-op: reboot with nothing changed completes promptly and keeps the index intact", async ({ sbServer, page }) => {
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
|
||||
const start = Date.now();
|
||||
await reboot(page);
|
||||
expect(Date.now() - start).toBeLessThan(15_000);
|
||||
|
||||
// The pre-existing index is still intact.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "index" select name`),
|
||||
).toEqual(["index"]);
|
||||
});
|
||||
|
||||
test("in-flight concurrency: reboot converges while file operations are in flight", async ({ sbServer, page }) => {
|
||||
await gotoSilverBulletPage(page, sbServer);
|
||||
await writeFile(join(sbServer.spaceDir, "Concurrent.md"), "# Concurrent\nconcurrent check\n");
|
||||
|
||||
// Drive operationCount > 0 (the deferred-fetchFileList path) by firing a
|
||||
// burst of concurrent reads, and invoke system.reboot in the same batch.
|
||||
await page.evaluate(async () => {
|
||||
const client = (globalThis as any).client;
|
||||
const esp = client.eventedSpacePrimitives;
|
||||
const reads: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
reads.push(esp.readFile("index.md").catch(() => {}));
|
||||
}
|
||||
await Promise.all([
|
||||
...reads,
|
||||
client.clientSystem.localSyscall("system.reboot", []),
|
||||
]);
|
||||
});
|
||||
|
||||
// No sleep: despite the in-flight reads, the change must be indexed by
|
||||
// the time reboot resolved.
|
||||
expect(
|
||||
await query(page, `from index.tag "page" where name == "Concurrent" select name`),
|
||||
).toEqual(["Concurrent"]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from "../../client/space_lua/query_collection.ts";
|
||||
import { syscall } from "@silverbulletmd/silverbullet/syscall";
|
||||
import type { ObjectValue } from "../../plug-api/types/index.ts";
|
||||
|
||||
/**
|
||||
* Exposes the SilverBullet object indexing system
|
||||
* @module
|
||||
@@ -200,3 +199,20 @@ export function resolveAnchor(
|
||||
): Promise<ResolveAnchorResult> {
|
||||
return syscall("index.resolveAnchor", name, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of tag name → raw JSON Schema for every defined object-type /
|
||||
* [[Tag]] that declares a schema. Tags without a schema are omitted.
|
||||
* Use it to discover what attributes a tag's objects carry before querying them.
|
||||
*/
|
||||
export function describeSchema(): Promise<Record<string, unknown>> {
|
||||
return syscall("index.describeSchema");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw JSON Schema for a single tag, or null if the tag is not
|
||||
* defined or has no schema.
|
||||
*/
|
||||
export function tagSchema(tagName: string): Promise<unknown | null> {
|
||||
return syscall("index.tagSchema", tagName);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ export function reloadPlugs(): Promise<void> {
|
||||
return syscall("system.reloadPlugs");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make edited-on-disk state live and wait until the client is ready again.
|
||||
*/
|
||||
export function reboot(): Promise<void> {
|
||||
return syscall("system.reboot");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (or reload) a single plug from a space file path.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user