Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ This file is a quick operational guide for coding agents working in this reposit
- `src/lib/stores/opfs-arrow-cache.ts` (OPFS tier under the query store: raw compressed Arrow IPC bytes, survives reloads/restarts)
- `src/lib/stores/query-history.ts` (persisted log of executed queries; recorded by `queryStore.ensure()`, consumed by `queries/query-history`)
- `src/lib/services/beacon-node.ts` (the single owner of the Beacon node list and the selection; read `$nodes` / `$currentNode` in a component, and write only through its actions)
- A child `onMount` runs before the layout `onMount`. State that a page reads at mount
therefore cannot come from `+layout.svelte` `onMount`. The public node list is fetched
there, so a first-ever visit has no node list when a page mounts. A page that names a
node by URL (a share link) must wait for `openNodesSettled` / `whenOpenNodesSettled()`
(`services/open-nodes-import.ts`) before it reports that node as missing. `AppSidebar`
blocks a route that needs a node, and waits for the same flag, so a first visit is not
sent home before the public nodes land.
- `src/lib/stores/settings.ts` (persisted user settings; the `/settings` page builds its form from `SETTING_DEFINITIONS`. Read a value with `getSettings()` at the point of use, or `$settings` in a component. Never read it at module load.)
- `src/lib/stores/toasts.ts` (global toasts)
- Heavy data operations (off-main-thread, one shared worker via `getArrowWorker()`):
Expand Down
12 changes: 6 additions & 6 deletions src/lib/components/query-builder/ParameterFilter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
{:else if filter.type === 'greater_than_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-greater-than-numeric`} class="input-label">Greater than</label>
<Input id={`${uid}-greater-than-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-greater-than-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'greater_than_string'}
Expand All @@ -69,7 +69,7 @@
{:else if filter.type === 'greater_than_or_equals_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-greater-than-or-equals-numeric`} class="input-label">Greater or Equal than</label>
<Input id={`${uid}-greater-than-or-equals-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-greater-than-or-equals-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'greater_than_or_equals_string'}
Expand All @@ -87,7 +87,7 @@
{:else if filter.type === 'equals_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-equals-numeric`} class="input-label">Equals</label>
<Input id={`${uid}-equals-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-equals-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'equals_string'}
Expand All @@ -105,7 +105,7 @@
{:else if filter.type === 'not_equals_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-not-equals-numeric`} class="input-label">Does NOT Equal</label>
<Input id={`${uid}-not-equals-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-not-equals-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'not_equals_string'}
Expand All @@ -123,7 +123,7 @@
{:else if filter.type === 'less_than_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-less-than-numeric`} class="input-label">Less than</label>
<Input id={`${uid}-less-than-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-less-than-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'less_than_string'}
Expand All @@ -142,7 +142,7 @@
{:else if filter.type === 'less_than_or_equals_numeric'}
<div class="advanced-parameter-row">
<label for={`${uid}-less-than-or-equals-numeric`} class="input-label">Less or Equal than</label>
<Input id={`${uid}-less-than-or-equals-numeric`} type="text" bind:value={filter.value} />
<Input id={`${uid}-less-than-or-equals-numeric`} type="number" bind:value={filter.value} />
</div>

{:else if filter.type === 'less_than_or_equals_string'}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/components/query-builder/QueryActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions
message: `Add the Beacon node ${missing} to run this query.`,
type: 'warning'
});
} else if (!workspace.nodesReady) {
// The public list is still on its way, so the node can still arrive.
addToast({ message: 'Wait for the Beacon nodes to load.', type: 'warning' });
} else {
addToast({ message: 'Pick a Beacon node for this query first.', type: 'warning' });
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/components/query-builder/QueryBuilder.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
let {
node,
missingNodeUrl = null,
nodesReady = true,
onNodeChange,
onSeedMismatch,
initialDraft = null,
Expand All @@ -41,6 +42,8 @@
node: BeaconNode | null;
/** The URL of a node that the node list does not hold, or null. */
missingNodeUrl?: string | null;
/** False while the app still reads the public node list. */
nodesReady?: boolean;
/** Called with the node the user picked in the first step. */
onNodeChange: (node: BeaconNode) => void;
/**
Expand Down Expand Up @@ -209,6 +212,7 @@
<QueryBuilderNodeSelector
selected={node}
missingUrl={missingNodeUrl}
{nodesReady}
onPick={onNodeChange}
/>

Expand Down
13 changes: 13 additions & 0 deletions src/lib/components/query-builder/QueryBuilderNodeSelector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@
let {
selected = null,
missingUrl = null,
nodesReady = true,
onPick
}: {
/** The node of the query now, or null while it has none. */
selected?: BeaconNode | null;
/** The URL of a node that the list does not hold, or null. */
missingUrl?: string | null;
/** False while the app still reads the public node list. */
nodesReady?: boolean;
/** Called with the node the user picked. */
onPick: (node: BeaconNode) => void;
} = $props();
Expand Down Expand Up @@ -95,6 +98,10 @@
</div>
</div>

{#if !nodesReady && $nodes.length === 0}
<p class="node-loading">Load Beacon nodes...</p>
{/if}

{#if missingUrl}
<div class="missing-node">
<TriangleAlertIcon size="1rem" />
Expand Down Expand Up @@ -177,6 +184,12 @@
}
}

p.node-loading {
margin: 1rem 0 0;
font-size: 0.875rem;
color: var(--muted-foreground);
}

.missing-node {
display: flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
<QueryBuilder
node={activeNode}
missingNodeUrl={workspace.missingNodeUrl}
nodesReady={workspace.nodesReady}
onNodeChange={handleNodeChange}
onSeedMismatch={handleSeedMismatch}
initialDraft={workspace.activeBlock?.draft ?? null}
Expand Down
34 changes: 29 additions & 5 deletions src/lib/components/query-builder/QueryWorkspace.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ import type { ResolvedUrlQuery } from '@/stores/query-library';
import {
cloneStoredQuery,
hasNodeRef,
nodeRefFromUrl,
snapshotNode,
type NodeRef,
type MapViewState,
type StoredQuery
} from '@/stores/stored-query';
import { getCurrentNode, nodes, matchRef } from '@/services/beacon-node';
import { getCurrentNode, nodes, matchRef, resolveRef } from '@/services/beacon-node';
import { openNodesSettled, whenOpenNodesSettled } from '@/services/open-nodes-import';
import type { BeaconNode } from '@/beacon-api/types';
import { addToast } from '@/stores/toasts';
import { makeEmptyQuerySelectionStatus, type QuerySelectionStatus } from '@/query/selection-status';
Expand Down Expand Up @@ -109,6 +111,9 @@ export class QueryWorkspace {
*/
private nodeList = $state<BeaconNode[]>([]);

/** True after the public list settles. Both node warnings wait for it. */
private nodesSettled = $state(false);

/**
* The blocks whose node the app guessed. A share link of an older app version
* carries no `?node=`, so the block falls back to the default node. That
Expand All @@ -120,7 +125,7 @@ export class QueryWorkspace {
*/
private guessedNode = $state<Record<string, boolean>>({});

/** Releases the two store subscriptions. See {@link destroy}. */
/** Releases the store subscriptions. See {@link destroy}. */
private unsubscribe: () => void;

constructor() {
Expand All @@ -132,9 +137,14 @@ export class QueryWorkspace {
this.nodeList = list;
});

const stopSettled = openNodesSettled.subscribe((value) => {
this.nodesSettled = value;
});

this.unsubscribe = () => {
stopBlocks();
stopNodes();
stopSettled();
};

this.restoreSelection();
Expand Down Expand Up @@ -194,9 +204,14 @@ export class QueryWorkspace {
private warnMissingNode(url: string | null): void {
if (!url) return;

addToast({
type: 'warning',
message: `This query needs the Beacon node ${url}. Add it to run the query.`
// The public list can still add this node. Ask again after it settles.
void whenOpenNodesSettled().then(() => {
if (resolveRef(nodeRefFromUrl(url))) return;

addToast({
type: 'warning',
message: `This query needs the Beacon node ${url}. Add it to run the query.`
});
});
Comment on lines +208 to 215
}

Expand Down Expand Up @@ -242,6 +257,10 @@ export class QueryWorkspace {
missingNodeUrlFor(block: StoredQuery | null): string | null {
if (!hasNodeRef(block?.node)) return null;
if (this.nodeFor(block)) return null;

// The public list can still add this node. Claim nothing until it settles.
if (!this.nodesSettled) return null;

return block?.node.url || null;
}

Expand All @@ -250,6 +269,11 @@ export class QueryWorkspace {
return this.missingNodeUrlFor(this.activeBlock);
}

/** True after the public list settles. See {@link missingNodeUrlFor}. */
get nodesReady(): boolean {
return this.nodesSettled;
}

/**
* Put a node on a block.
*
Expand Down
57 changes: 49 additions & 8 deletions src/lib/components/sidebar/AppSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
// Node service
import { currentNode, nodes, selectFirstIfNone } from '@/services/beacon-node';
import { ensureFresh } from '@/services/beacon-node-connect';
import { openNodesSettled } from '@/services/open-nodes-import';
import logo from '$lib/assets/logo-gradient.svg';

// Svelte lifecycle and navigation
import { onMount } from 'svelte';
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { addToast } from '@/stores/toasts';
import { addToast, dismissToast } from '@/stores/toasts';

// Icons
import EyeIcon from '@lucide/svelte/icons/eye';
import TextSearchIcon from '@lucide/svelte/icons/text-search';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import InfoIcon from '@lucide/svelte/icons/info';
import Table2Icon from '@lucide/svelte/icons/table-2';
import LinkIcon from '@lucide/svelte/icons/link-2';
import CpuIcon from '@lucide/svelte/icons/cpu';
Expand Down Expand Up @@ -100,6 +102,12 @@
];

const footer: MenuItem[] = [
{
title: 'More about Studio',
url: 'https://beacon-datalake.org/ecosystem/beacon-studio',
icon: InfoIcon,
target: '_blank'
},
{
title: 'Documentation',
url: 'https://maris-development.github.io/beacon/',
Expand Down Expand Up @@ -150,6 +158,8 @@
// record holds its own node. Block the navigation instead of following it, so
// the user stays on the page they came from and sees why the target failed.
beforeNavigate((navigation) => {
// The public list can still add a node. Let the move through until it lands.
if (!$openNodesSettled) return;
if ($nodes.length > 0) return;
if (!needsNode(navigation.to?.route.id)) return;

Expand All @@ -172,15 +182,46 @@
// and the builder asks for one where it is missing.
selectFirstIfNone();

// `beforeNavigate` never runs for the page a session opens on. A direct
// load of a blocked route (a bookmark, a refresh, a shared link) has no
// prior page to stay on, so send it home instead.
if ($nodes.length === 0 && needsNode(page.route.id)) {
warnNoNode();
goto(resolve('/'));
return () => mobileQuery.removeEventListener('change', onMobileChange);
});

// `beforeNavigate` never runs for the page a session opens on. A direct load of
// a blocked route (a bookmark, a refresh, a shared link) has no prior page to
// stay on, so send it home instead. A first visit has no saved node, so this
// waits for the public list before the app gives up on the page.
$effect(() => {
if (!$openNodesSettled) return;
if ($nodes.length > 0) return;
if (!needsNode(page.route.id)) return;

warnNoNode();
goto(resolve('/'));
});

// The id of the open "please wait" toast. A plain let, so the effect below
// does not re-run on its own write.
let waitToastId: number | null = null;

// Name the wait. The app reads the public node list on a first visit only.
$effect(() => {
if ($openNodesSettled) {
if (waitToastId !== null) {
dismissToast(waitToastId);
waitToastId = null;
}
return;
}

return () => mobileQuery.removeEventListener('change', onMobileChange);
if (waitToastId !== null) return;
if ($nodes.length > 0) return;
if (!needsNode(page.route.id)) return;

waitToastId = addToast({
type: 'info',
timeout: 0,
message:
'Beacon Studio reads the public Beacon nodes. This happens on the first visit only. Your query starts when they arrive.'
});
});

// Close the overlay sidebar after navigating on mobile
Expand Down
Loading