config: enforce browser preflight and generated DTOs

This commit is contained in:
2026-08-14 01:27:29 +09:00
parent fa9bd8207f
commit 544abbbf56
29 changed files with 259 additions and 128 deletions
@@ -25,6 +25,8 @@
let baseRevision = $state(0);
let baseDigest = $state("");
let renamePath = $state("");
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state("");
let toolchain: ConfigSourceToolchain | null = null;
const paths = $derived(
@@ -34,6 +36,7 @@
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
);
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
onMount(() => {
toolchain = new ConfigSourceToolchain();
@@ -48,6 +51,7 @@
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
baseSnapshot = structuredClone(treeState.snapshot);
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot);
@@ -65,13 +69,10 @@
async function stageCurrent() {
const change = currentChange();
if (!change || !toolchain) return;
draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change];
const candidate = await toolchain.applyChanges([change]);
if (treeState) treeState = { ...treeState, snapshot: candidate };
}
function changeTouches(change: ConfigTreeChange, path: string): boolean {
return change.kind === "rename" ? change.from === path || change.to === path : change.path === path;
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
}
async function select(path: string) {
@@ -144,6 +145,7 @@
toolchain_fingerprint: treeState.contract.fingerprint,
});
diagnostics = [];
preflightDigest = candidate.snapshot.digest;
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
} catch (error) {
status = String(error);
@@ -159,6 +161,10 @@
status = "No draft changes to commit.";
return;
}
if (preflightDigest !== treeState.snapshot.digest) {
status = "Preview the complete candidate successfully before Commit.";
return;
}
busy = true;
try {
treeState = await commitConfigTree(workspaceId, {
@@ -169,6 +175,8 @@
toolchain_fingerprint: treeState.contract.fingerprint,
});
draftChanges = [];
preflightDigest = "";
baseSnapshot = structuredClone(treeState.snapshot);
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot);
@@ -198,9 +206,10 @@
path: selectedPath,
expected_digest: selected.content_digest,
};
draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change];
const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
source = selectedPath ? candidate.entries[selectedPath].content : "";
renamePath = selectedPath;
@@ -218,9 +227,10 @@
to,
expected_digest: selected.content_digest,
};
draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change];
const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
selectedPath = to;
source = candidate.entries[to]?.content ?? "";
status = `Rename to ${to} staged. Preview and Commit to persist.`;
@@ -261,7 +271,7 @@
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || busy}>Delete</button>
</div>
</header>
@@ -5,7 +5,9 @@ export function analyze_snapshot(snapshot: any, entrypoint: string, source_overr
export function apply_changes(changes: any): any;
export function complete_current(entrypoint: string, source: string, utf8_byte_offset: number, explicit: boolean): any;
export function changes_between(base: any, candidate: any): any;
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
export function evaluate_current(contract: any): any;
@@ -23,6 +25,7 @@ export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number];
readonly apply_changes: (a: any) => [number, number, number];
readonly changes_between: (a: any, b: any) => [number, number, number];
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
readonly evaluate_current: (a: any) => [number, number, number];
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
@@ -30,19 +30,32 @@ export function apply_changes(changes) {
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {any} base
* @param {any} candidate
* @returns {any}
*/
export function changes_between(base, candidate) {
const ret = wasm.changes_between(base, candidate);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} entrypoint
* @param {string} source
* @param {number} utf8_byte_offset
* @param {number} utf16_offset
* @param {boolean} explicit
* @returns {any}
*/
export function complete_current(entrypoint, source, utf8_byte_offset, explicit) {
export function complete_current(entrypoint, source, utf16_offset, explicit) {
const ptr0 = passStringToWasm0(entrypoint, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.complete_current(ptr0, len0, ptr1, len1, utf8_byte_offset, explicit);
const ret = wasm.complete_current(ptr0, len0, ptr1, len1, utf16_offset, explicit);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
@@ -3,6 +3,7 @@
export const memory: WebAssembly.Memory;
export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number];
export const apply_changes: (a: any) => [number, number, number];
export const changes_between: (a: any, b: any) => [number, number, number];
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
export const evaluate_current: (a: any) => [number, number, number];
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath";
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, toolchain_fingerprint: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConfigContentType = "decodal" | "text";
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigDiagnosticLabel } from "./ConfigDiagnosticLabel";
import type { ConfigSpan } from "./ConfigSpan";
import type { VirtualPath } from "./VirtualPath";
export type ConfigDiagnostic = { path: VirtualPath, revision: number, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array<ConfigDiagnosticLabel>, notes: Array<string>, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigSpan } from "./ConfigSpan";
export type ConfigDiagnosticLabel = { span: ConfigSpan, message: string, };
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigContentType } from "./ConfigContentType";
import type { VirtualPath } from "./VirtualPath";
export type ConfigEntry = { path: VirtualPath, content_type: ConfigContentType, content: string, content_digest: string, };
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath";
export type ConfigPreviewRequest = { changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConfigSpan = { start_byte: number, end_byte: number, };
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigContentType } from "./ConfigContentType";
import type { VirtualPath } from "./VirtualPath";
export type ConfigTreeChange = { "kind": "create", path: VirtualPath, content_type: ConfigContentType, content: string, } | { "kind": "update", path: VirtualPath, expected_digest: string, content: string, } | { "kind": "rename", from: VirtualPath, to: VirtualPath, expected_digest: string, } | { "kind": "delete", path: VirtualPath, expected_digest: string, };
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigEntry } from "./ConfigEntry";
import type { VirtualPath } from "./VirtualPath";
export type ConfigTreeSnapshot = { revision: number, digest: string, entries: { [key in VirtualPath]: ConfigEntry }, };
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeSnapshot } from "./ConfigTreeSnapshot";
import type { EvaluationResult } from "./EvaluationResult";
import type { ToolchainContract } from "./ToolchainContract";
export type EvaluatedConfigCandidate = { base_revision: number, base_digest: string, snapshot: ConfigTreeSnapshot, contract: ToolchainContract, evaluation: EvaluationResult, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { VirtualPath } from "./VirtualPath";
export type EvaluatedProjection = { entrypoint: VirtualPath, data_json: unknown, projection_digest: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { EvaluatedProjection } from "./EvaluatedProjection";
export type EvaluationResult = { projections: Array<EvaluatedProjection>, projection_digest: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { VirtualPath } from "./VirtualPath";
export type ToolchainContract = { contract_version: number, decodal_version: string, schema_version: number, entrypoints: Array<VirtualPath>, import_policy_version: number, fingerprint: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type VirtualPath = string;
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeSnapshot } from "./ConfigTreeSnapshot";
import type { ToolchainContract } from "./ToolchainContract";
export type WorkspaceConfigState = { snapshot: ConfigTreeSnapshot, contract: ToolchainContract, projection_digest: string, };
@@ -4,6 +4,7 @@ import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./to
type Command =
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
@@ -31,14 +32,17 @@ export class ConfigSourceToolchain {
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
return this.#request({ kind: "apply_changes", changes });
}
changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise<ConfigTreeChange[]> {
return this.#request({ kind: "changes_between", base, candidate });
}
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
return this.#request({ kind: "analyze", path, source });
}
evaluate(contract: ToolchainContract) {
return this.#request({ kind: "evaluate", contract });
}
complete(path: string, source: string, utf8ByteOffset: number, explicit = false): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
return this.#request({ kind: "complete", path, source, utf8ByteOffset, explicit });
complete(path: string, source: string, utf16Offset: number, explicit = false): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
return this.#request({ kind: "complete", path, source, utf16Offset, explicit });
}
format(source: string): Promise<string> {
return this.#request({ kind: "format", source });
@@ -1,6 +1,7 @@
import init, {
analyze_snapshot,
apply_changes,
changes_between,
complete_current,
evaluate_current,
format_source,
@@ -11,9 +12,10 @@ import type { ConfigTreeChange } from "./types.ts";
export type ConfigSourceWorkerRequest =
| { id: number; kind: "set_snapshot"; snapshot: unknown }
| { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
| { id: number; kind: "analyze"; path: string; source?: string }
| { id: number; kind: "evaluate"; contract: unknown }
| { id: number; kind: "complete"; path: string; source: string; utf8ByteOffset: number; explicit: boolean }
| { id: number; kind: "complete"; path: string; source: string; utf16Offset: number; explicit: boolean }
| { id: number; kind: "format"; source: string };
export type ConfigSourceWorkerResponse =
@@ -38,6 +40,9 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
snapshot = apply_changes(request.changes);
result = snapshot;
break;
case "changes_between":
result = changes_between(request.base, request.candidate);
break;
case "analyze":
if (!snapshot) throw new Error("config source snapshot is not initialized");
result = analyze_snapshot(snapshot, request.path, request.source);
@@ -46,7 +51,7 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
result = evaluate_current(request.contract);
break;
case "complete":
result = complete_current(request.path, request.source, request.utf8ByteOffset, request.explicit);
result = complete_current(request.path, request.source, request.utf16Offset, request.explicit);
break;
case "format":
result = format_source(request.source);
@@ -1,93 +1,16 @@
export type VirtualPath = string;
export type ConfigContentType = "decodal" | "text";
export interface ConfigEntry {
path: VirtualPath;
content_type: ConfigContentType;
content: string;
content_digest: string;
}
export interface ConfigTreeSnapshot {
revision: number;
digest: string;
entries: Record<VirtualPath, ConfigEntry>;
}
export type ConfigTreeChange =
| {
kind: "create";
path: VirtualPath;
content_type: ConfigContentType;
content: string;
}
| {
kind: "update";
path: VirtualPath;
expected_digest: string;
content: string;
}
| {
kind: "rename";
from: VirtualPath;
to: VirtualPath;
expected_digest: string;
}
| {
kind: "delete";
path: VirtualPath;
expected_digest: string;
};
export interface ToolchainContract {
contract_version: number;
decodal_version: string;
schema_version: number;
entrypoints: VirtualPath[];
import_policy_version: number;
fingerprint: string;
}
export interface ConfigDiagnostic {
path: VirtualPath;
revision: number;
tree_digest: string;
kind: string;
span: { start_byte: number; end_byte: number };
message: string;
labels: Array<{
span: { start_byte: number; end_byte: number };
message: string;
}>;
notes: string[];
}
export interface WorkspaceConfigTreeResponse {
snapshot: ConfigTreeSnapshot;
contract: ToolchainContract;
projection_digest: string;
}
export interface EvaluatedConfigCandidate {
base_revision: number;
base_digest: string;
snapshot: ConfigTreeSnapshot;
contract: ToolchainContract;
evaluation: {
projections: Array<{
entrypoint: VirtualPath;
data_json: unknown;
projection_digest: string;
}>;
projection_digest: string;
};
}
export interface ConfigCommitRequest {
base_revision: number;
base_digest: string;
changes: ConfigTreeChange[];
entrypoints: VirtualPath[];
toolchain_fingerprint: string;
}
// Core and HTTP DTOs are generated from Rust with ts-rs.
export type { ConfigContentType } from "./generated/types/ConfigContentType.ts";
export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts";
export type { ConfigDiagnosticLabel } from "./generated/types/ConfigDiagnosticLabel.ts";
export type { ConfigEntry } from "./generated/types/ConfigEntry.ts";
export type { ConfigSpan } from "./generated/types/ConfigSpan.ts";
export type { ConfigTreeChange } from "./generated/types/ConfigTreeChange.ts";
export type { ConfigTreeSnapshot } from "./generated/types/ConfigTreeSnapshot.ts";
export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts";
export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";
export type { ToolchainContract } from "./generated/types/ToolchainContract.ts";
export type { VirtualPath } from "./generated/types/VirtualPath.ts";
export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts";
export type { ConfigPreviewRequest } from "./generated/types/ConfigPreviewRequest.ts";
export type { EvaluatedConfigCandidate } from "./generated/types/EvaluatedConfigCandidate.ts";
export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts";
@@ -16,7 +16,7 @@
readonly?: boolean;
ariaLabel?: string;
onChange?: (value: string) => void;
onComplete?: (source: string, utf8ByteOffset: number, explicit: boolean) => Promise<CompletionResult | null>;
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
} = $props();
let host = $state<HTMLDivElement | null>(null);
@@ -54,8 +54,7 @@
decodal(),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString();
const utf8ByteOffset = new TextEncoder().encode(doc.slice(0, context.pos)).byteLength;
return await handleComplete(doc, utf8ByteOffset, context.explicit);
return await handleComplete(doc, context.pos, context.explicit);
}] })] : []),
keymap.of([]),
EditorState.readOnly.of(initialReadonly),