config: require schema assertion on main
This commit is contained in:
@@ -584,7 +584,7 @@ impl WorkspaceConfigSchemaBundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let source = if contributions.is_empty() {
|
let source = if contributions.is_empty() {
|
||||||
"{}".to_string()
|
"{ ...Unknown }".to_string()
|
||||||
} else {
|
} else {
|
||||||
contributions
|
contributions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1012,7 +1012,10 @@ impl SnapshotEnvironment {
|
|||||||
&error.to_string(),
|
&error.to_string(),
|
||||||
)]);
|
)]);
|
||||||
}
|
}
|
||||||
let service = LanguageService::new(self);
|
let analysis_environment = self
|
||||||
|
.clone()
|
||||||
|
.with_schema_bundle(contract.schema_bundle.clone());
|
||||||
|
let service = LanguageService::new(&analysis_environment);
|
||||||
let diagnostics = self
|
let diagnostics = self
|
||||||
.snapshot
|
.snapshot
|
||||||
.entries
|
.entries
|
||||||
@@ -1847,6 +1850,22 @@ mod tests {
|
|||||||
assert_eq!(left.digest, right.digest);
|
assert_eq!(left.digest, right.digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entrypoint_can_assert_empty_workspace_schema_global() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
1,
|
||||||
|
[entry(
|
||||||
|
"main.dcdl",
|
||||||
|
"{ answer = 42; } as WorkspaceConfigSchema",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
|
||||||
|
SnapshotEnvironment::new(snapshot)
|
||||||
|
.evaluate_contract(&contract)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_completion_tracks_only_asserted_config_object_paths() {
|
fn schema_completion_tracks_only_asserted_config_object_paths() {
|
||||||
assert_eq!(config_field_completion_context("{ pro", 5), None);
|
assert_eq!(config_field_completion_context("{ pro", 5), None);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::{Error, Result, SqliteWorkspaceStore};
|
use crate::{Error, Result, SqliteWorkspaceStore};
|
||||||
|
|
||||||
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
||||||
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
|
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{} as WorkspaceConfigSchema\n";
|
||||||
|
const WORKSPACE_CONFIG_SCHEMA_ASSERTION: &str = "WorkspaceConfigSchema";
|
||||||
const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20;
|
const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20;
|
||||||
|
|
||||||
fn toolchain_upgrade_diagnostics(mut diagnostics: Vec<ConfigDiagnostic>) -> Error {
|
fn toolchain_upgrade_diagnostics(mut diagnostics: Vec<ConfigDiagnostic>) -> Error {
|
||||||
@@ -175,8 +176,13 @@ impl SqliteWorkspaceStore {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok((state, requires_toolchain_refresh))
|
Ok((state, requires_toolchain_refresh))
|
||||||
})?;
|
})?;
|
||||||
|
let main_needs_normalization = state
|
||||||
|
.snapshot
|
||||||
|
.get(&main_config_path())
|
||||||
|
.is_some_and(|entry| !main_config_has_schema_assertion(&entry.content));
|
||||||
if requires_toolchain_refresh
|
if requires_toolchain_refresh
|
||||||
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
||||||
|
|| main_needs_normalization
|
||||||
{
|
{
|
||||||
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
||||||
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
||||||
@@ -403,6 +409,53 @@ impl SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn main_config_has_schema_assertion(source: &str) -> bool {
|
||||||
|
source.starts_with('{')
|
||||||
|
&& source
|
||||||
|
.trim_end()
|
||||||
|
.ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_main_config_schema_assertion(
|
||||||
|
snapshot: ConfigTreeSnapshot,
|
||||||
|
) -> Result<ConfigTreeSnapshot> {
|
||||||
|
let main_path = main_config_path();
|
||||||
|
let main = snapshot.get(&main_path).ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"workspace config snapshot must contain {MAIN_CONFIG_ENTRYPOINT}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if main_config_has_schema_assertion(&main.content) {
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = main.content.trim();
|
||||||
|
let normalized = if source.starts_with('{')
|
||||||
|
&& source.ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"))
|
||||||
|
{
|
||||||
|
format!("{source}\n")
|
||||||
|
} else if source.starts_with('{') && source.ends_with('}') {
|
||||||
|
format!("{source} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}\n")
|
||||||
|
} else {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"{MAIN_CONFIG_ENTRYPOINT} must be a top-level object so it can be asserted as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let revision = snapshot.revision;
|
||||||
|
let mut entries = Vec::with_capacity(snapshot.entries.len());
|
||||||
|
for entry in snapshot.entries.into_values() {
|
||||||
|
if entry.path == main_path {
|
||||||
|
entries.push(
|
||||||
|
ConfigEntry::new(entry.path, entry.content_type, normalized.clone())
|
||||||
|
.map_err(config_error)?,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error)
|
||||||
|
}
|
||||||
|
|
||||||
fn evaluate_candidate(
|
fn evaluate_candidate(
|
||||||
current: WorkspaceConfigState,
|
current: WorkspaceConfigState,
|
||||||
changes: &[ConfigTreeChange],
|
changes: &[ConfigTreeChange],
|
||||||
@@ -411,6 +464,7 @@ fn evaluate_candidate(
|
|||||||
reject_main_entrypoint_mutation(changes)?;
|
reject_main_entrypoint_mutation(changes)?;
|
||||||
let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
|
let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
|
||||||
ensure_main_entrypoint(&snapshot)?;
|
ensure_main_entrypoint(&snapshot)?;
|
||||||
|
let snapshot = normalize_main_config_schema_assertion(snapshot)?;
|
||||||
let contract = main_config_contract_with_schema(schema_bundle);
|
let contract = main_config_contract_with_schema(schema_bundle);
|
||||||
let evaluation = SnapshotEnvironment::new(snapshot.clone())
|
let evaluation = SnapshotEnvironment::new(snapshot.clone())
|
||||||
.evaluate_contract(&contract)
|
.evaluate_contract(&contract)
|
||||||
@@ -1264,6 +1318,103 @@ mod tests {
|
|||||||
.content,
|
.content,
|
||||||
DEFAULT_MAIN_CONFIG_SOURCE
|
DEFAULT_MAIN_CONFIG_SOURCE
|
||||||
);
|
);
|
||||||
|
assert!(main_config_has_schema_assertion(DEFAULT_MAIN_CONFIG_SOURCE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn candidate_normalizes_main_schema_assertion_only_for_entrypoint() {
|
||||||
|
let store = open_store().await;
|
||||||
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
|
let candidate = store
|
||||||
|
.preview_workspace_config(
|
||||||
|
"w-config",
|
||||||
|
&ConfigPreviewRequest {
|
||||||
|
changes: vec![
|
||||||
|
update_main(¤t, "{}"),
|
||||||
|
ConfigTreeChange::Create {
|
||||||
|
path: path("module.dcdl"),
|
||||||
|
content_type: ConfigContentType::Decodal,
|
||||||
|
content: "{}".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
candidate
|
||||||
|
.snapshot
|
||||||
|
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||||
|
.unwrap()
|
||||||
|
.content,
|
||||||
|
DEFAULT_MAIN_CONFIG_SOURCE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidate
|
||||||
|
.snapshot
|
||||||
|
.get(&path("module.dcdl"))
|
||||||
|
.unwrap()
|
||||||
|
.content,
|
||||||
|
"{}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn materialization_upgrades_legacy_main_and_preserves_prior_revision() {
|
||||||
|
let store = open_store().await;
|
||||||
|
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||||
|
let legacy_snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
current.snapshot.revision + 1,
|
||||||
|
[ConfigEntry::new(
|
||||||
|
path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
|
ConfigContentType::Decodal,
|
||||||
|
"{}",
|
||||||
|
)
|
||||||
|
.unwrap()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let legacy = WorkspaceConfigState {
|
||||||
|
projection_digest: current.projection_digest.clone(),
|
||||||
|
snapshot: legacy_snapshot,
|
||||||
|
contract: current.contract.clone(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.with_conn_mut(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
|
||||||
|
params!["w-config"],
|
||||||
|
)?;
|
||||||
|
insert_materialized_state(conn, "w-config", &legacy, "2026-08-14T00:00:00Z")
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let upgraded = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-14T00:00:01Z",
|
||||||
|
current.contract.schema_bundle.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(upgraded.snapshot.revision, legacy.snapshot.revision + 1);
|
||||||
|
assert_eq!(
|
||||||
|
upgraded
|
||||||
|
.snapshot
|
||||||
|
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||||
|
.unwrap()
|
||||||
|
.content,
|
||||||
|
DEFAULT_MAIN_CONFIG_SOURCE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load_workspace_config_revision("w-config", legacy.snapshot.revision)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.get(&path(MAIN_CONFIG_ENTRYPOINT))
|
||||||
|
.unwrap()
|
||||||
|
.content,
|
||||||
|
"{}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const MAIN_ENTRYPOINT = "main.dcdl";
|
const MAIN_ENTRYPOINT = "main.dcdl";
|
||||||
const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema";
|
|
||||||
const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`;
|
|
||||||
|
|
||||||
let { workspaceId }: { workspaceId: string } = $props();
|
let { workspaceId }: { workspaceId: string } = $props();
|
||||||
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
||||||
@@ -42,7 +40,6 @@
|
|||||||
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
||||||
);
|
);
|
||||||
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
|
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
|
||||||
const mainSchemaWrapped = $derived(mainSelected && hasWorkspaceSchemaWrapper(source));
|
|
||||||
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
||||||
|
|
||||||
@@ -117,35 +114,6 @@
|
|||||||
return [MAIN_ENTRYPOINT];
|
return [MAIN_ENTRYPOINT];
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasWorkspaceSchemaWrapper(value: string): boolean {
|
|
||||||
const sourceWithoutTrailingWhitespace = value.trimEnd();
|
|
||||||
return value.startsWith("{") && sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wrapMainWithWorkspaceSchema() {
|
|
||||||
if (!toolchain || !mainSelected || mainSchemaWrapped || busy) return;
|
|
||||||
const candidate = source.trim();
|
|
||||||
if (!candidate.startsWith("{") || !candidate.endsWith("}")) {
|
|
||||||
status = "WorkspaceConfigSchema can only wrap a top-level object source.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
source = await toolchain.format(`${candidate} as ${WORKSPACE_SCHEMA_ASSERTION}`);
|
|
||||||
diagnostics = await toolchain.analyze(selectedPath, source);
|
|
||||||
preflightDigest = "";
|
|
||||||
candidateContract = null;
|
|
||||||
conflict = false;
|
|
||||||
status = diagnostics.length === 0
|
|
||||||
? "WorkspaceConfigSchema wrapper staged. Preview and Commit to persist it."
|
|
||||||
: `${diagnostics.length} diagnostic(s) after adding WorkspaceConfigSchema.`;
|
|
||||||
} catch (error) {
|
|
||||||
status = String(error);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function analyze() {
|
async function analyze() {
|
||||||
if (!toolchain || !treeState || !selectedPath) return;
|
if (!toolchain || !treeState || !selectedPath) return;
|
||||||
diagnostics = await toolchain.analyze(selectedPath, source);
|
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||||
@@ -344,9 +312,6 @@
|
|||||||
<div class="config-source-actions">
|
<div class="config-source-actions">
|
||||||
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
||||||
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
||||||
{#if mainSelected && !mainSchemaWrapped}
|
|
||||||
<button type="button" onclick={wrapMainWithWorkspaceSchema} disabled={!selected || busy}>Wrap with WorkspaceConfigSchema</button>
|
|
||||||
{/if}
|
|
||||||
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
||||||
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
||||||
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
||||||
@@ -357,7 +322,7 @@
|
|||||||
<DecodalSourceEditor
|
<DecodalSourceEditor
|
||||||
value={source}
|
value={source}
|
||||||
readonly={!selectedPath || busy}
|
readonly={!selectedPath || busy}
|
||||||
fixedSchemaWrapper={mainSchemaWrapped}
|
fixedSchemaWrapper={mainSelected}
|
||||||
onChange={(value) => source = value}
|
onChange={(value) => source = value}
|
||||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Binary file not shown.
@@ -80,7 +80,7 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("main config wrapper is an explicit canonical draft conversion", async () => {
|
Deno.test("main entrypoint always enables the fixed schema wrapper", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
||||||
@@ -89,15 +89,12 @@ Deno.test("main config wrapper is an explicit canonical draft conversion", async
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
source.includes("Wrap with WorkspaceConfigSchema") &&
|
source.includes("fixedSchemaWrapper={mainSelected}"),
|
||||||
source.includes("wrapMainWithWorkspaceSchema") &&
|
"main.dcdl should always enable fixed wrapper behavior",
|
||||||
source.includes("fixedSchemaWrapper={mainSchemaWrapped}"),
|
|
||||||
"legacy main sources should offer an explicit conversion before fixed wrapper mode is enabled",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
source.includes("source.trim()") &&
|
!source.includes("Wrap with WorkspaceConfigSchema") &&
|
||||||
source.includes("toolchain.format") &&
|
!source.includes("wrapMainWithWorkspaceSchema"),
|
||||||
source.includes("wrapper staged. Preview and Commit"),
|
"the authoritative main source should not require an optional client-side conversion",
|
||||||
"conversion should remain a formatted local draft until the normal commit flow",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const contract: ToolchainContract = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mainEntrypointContract: ToolchainContract = {
|
||||||
|
...contract,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
fingerprint: await toolchainFingerprint(["main.dcdl"], emptySchemaBundle),
|
||||||
|
};
|
||||||
|
|
||||||
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
||||||
const result = evaluate_snapshot(snapshot, contract) as {
|
const result = evaluate_snapshot(snapshot, contract) as {
|
||||||
projections: Array<{ data_json: { answer: number } }>;
|
projections: Array<{ data_json: { answer: number } }>;
|
||||||
@@ -88,6 +94,27 @@ Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
|||||||
assertEquals(result.projections[0].data_json, { answer: 42 });
|
assertEquals(result.projections[0].data_json, { answer: 42 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("generated WASM accepts the mandatory main schema assertion", () => {
|
||||||
|
const assertedSnapshot: ConfigTreeSnapshot = {
|
||||||
|
revision: 1,
|
||||||
|
digest: "sha256:asserted-main",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content: "{ answer = 42; } as WorkspaceConfigSchema\n",
|
||||||
|
content_digest: "sha256:asserted-main-entry",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = evaluate_snapshot(
|
||||||
|
assertedSnapshot,
|
||||||
|
mainEntrypointContract,
|
||||||
|
) as { projections: Array<{ data_json: { answer: number } }> };
|
||||||
|
|
||||||
|
assertEquals(result.projections[0].data_json, { answer: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("generated WASM diagnostics carry snapshot provenance", () => {
|
Deno.test("generated WASM diagnostics carry snapshot provenance", () => {
|
||||||
const diagnostics = analyze_snapshot(
|
const diagnostics = analyze_snapshot(
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
Reference in New Issue
Block a user