-
v0.14.0 Stable
released this
2026-10-24 13:49:27 +00:00 | 2 commits to main since this releaseVSKI v0.14.0
Pipelines: automations as data
Workflows have been VSKI's answer to "run this reliably" — but they live in
worker code you write, deploy and version yourself. v0.14.0 adds
pipelines: multi-step automations defined as JSON and edited on a visual
canvas, executed durably by the engine itself. No worker, no deployment —
the definition is the automation.A pipeline is a DAG of steps — call a plugin function, make an HTTP request,
compute values, wait on a timer, park for a human approval, spawn a child
workflow or pipeline — with per-step dependencies, conditions, retries,
timeouts and failure policies. Pipelines start from collection events
(record created/updated/deleted), cron schedules, or manually via API/UI.They are not a parallel universe: every pipeline run is an ordinary
workflow run namedpipeline.<name>, driven by the same event log, job
queues, waits and signals machinery. The run monitor, Gantt timeline, stats,
circuit breaker, execution timeouts and crash recovery all apply unchanged —
pipeline steps emit the standardstep_*events withcorrelationId=
step id, so the existing timeline renders them for free.The definition
{ "name": "note-intake", "triggers": [ { "type": "collection", "database": "default", "collection": "notes", "actions": ["create"] } ], "steps": [ { "id": "embed", "type": "plugin", "target": "indexer/reindex", "params": { "id": "{{trigger.record.id}}" }, "retries": 2 }, { "id": "approve", "type": "signal", "name": "approval", "dependsOn": ["embed"] }, { "id": "notify", "type": "http", "method": "POST", "url": "https://hooks.example.com/x", "body": { "id": "{{trigger.record.id}}", "by": "{{steps.approve.output.by}}" }, "onError": "continue", "dependsOn": ["approve"] } ], "output": { "indexed": "{{steps.embed.output.count}}" } }Step types:
plugin(warm in-process invoke,instance/function, timeout
1-120s),http(synchronous, every attempt in_webhook_logs, output
{status, body}),set(template evaluation into{{vars.*}}),delay
(durable wait — survives restarts, fires exactly at the resume time),
signal(approval gate — parks until a matching signal arrives, FIFO by
name, resumable viasendSignalor workflow hook tokens), and
workflow/pipelinespawn steps (fire-and-forget children linked via
parentRunId).Common step fields:
dependsOn(independent branches run in parallel,
bounded byoptions.concurrency),condition(data-only expression
language — comparisons,and/or/not, literals; sandboxed by construction),
retries(linear backoff),timeout,onError: "fail" | "continue"
(failed steps skip their transitive dependents while unrelated branches
continue;continuecaptures the error assteps.<id>.error).Templates:
{{trigger.*}},{{input.*}},{{steps.<id>.output|.status|.error}},
{{vars.*}},{{run.id}}- apply to any string in params/headers/body/input,
with array indexing; a whole-string template preserves the value's type.Execution semantics worth knowing
- Version pinning: each run snapshots the definition into its
executionContext. Editing or deleting a pipeline never mutates an
in-flight run. - At-least-once across crashes: the event log is the source of truth;
after a restart the dispatcher replays events and re-executes only steps
that hadn't completed (boot recovery re-pends orphaned jobs and requeues
active runs). - Per-run serialization: runs of one pipeline execute one at a time;
independent pipelines run concurrently. Steps of one run run in parallel
up tooptions.concurrency(default 4). - True suspension: while a run waits on a delay or signal it holds no
job; a failed branch doesn't fail the run while another branch still
waits. - Replicas stay cold: triggers are only registered and pipelines only
dispatched on the master.
Triggers
- Collection events: the pipeline subscribes from its definition, no
collection-options changes, no auth-header webhook wiring. Disabled
pipelines ignore events; the registry rebuilds on every definition change. - Cron:
{"type": "cron", "schedule": "0 9 * * 1-5"}inside the
definition registers an in-process named schedule (retired on
disable/update/delete, no cron-job rows). Ad-hoc: any cron job with
url: "pipeline://<name>"fires a pipeline. - Manual:
POST /api/pipelines/:name/runsor the UI Run dialog.
The builder
The dashboard grows a Pipelines section: a list (trigger chips, version,
last-run status) and a full-page visual builder on
React Flow - drag nodes to declaredependsOn,
per-type step inspectors parsed from live plugin catalogs (function lists
come from manifests), a JSON⇄Canvas toggle that round-trips the definition,
server-side Validate with per-step issues, and a Run dialog. A Runs
button polls the last 10 runs with a dot indicator (pulsing green while
anything is active), linking into the existing run views; the workflows page
accepts?workflowName=pipeline.<name>to pre-filter its list.API & SDKs
GET/POST /api/pipelines,GET/PATCH/DELETE /api/pipelines/:name
(definition replace bumpsversion; rename rejected),
POST /api/pipelines/validate(per-step issue maps: cycles, refs, cron,
durations, template/condition syntax, option ranges),
POST /api/pipelines/:name/runs. Auth: theworkflowsrole or admin, like
the rest of the workflow API.pipelinesis also queryable as a system
collection.- JS/TS (
@vski/sdk2.3.0):client.pipelines—list,get,
getDefinition,create,update,remove,validate,run; exports
Pipeline,PipelineDefinition,PipelineStep,PipelineTrigger,
ValidationIssuetypes. - Python (
vski-sdk0.7.0):client.pipelines—list,get,
get_definition,create,update,delete,validate,run.
Correctness fixes
plugin://cron jobs never worked — documented since the plugins
release, but the cron executor routed every command through the SQL
branch, soplugin://jobs failed as SQL syntax errors. Cronplugin://
commands now dispatch through the webhook retry/log pipeline over the
warm pipe, and replicas correctly skip all dispatch-type commands
(http/plugin/pipeline).ExecuteStandaloneWebhookdropped the job's database whenever a body was
set, breakingplugin://instance resolution for POST cron jobs.- The OpenAPI spec documented
POST/GET/PATCH/DELETE /workflows
definition CRUD endpoints that never existed (routes were never
implemented). The dead spec block is removed. - Workflows dashboard: pre-existing
'sdk' possibly nulltype errors fixed;
the run list prefills its filter from?workflowName=deep links.
Tests
test/e2e/55_pipelines_test.ts(9 steps): validation + CRUD, DAG with
parallel branches/join/condition skip/output templating, durable delay,
signal approval with templated payload, failure semantics (transitive
skips, unrelated branches complete,onError: continue), collection + cron
triggers (definition schedule andpipeline://command), spawn steps with
parentRunId, and replay-skip resume through the real dispatcher. The
engine gains its first Go unit-test suite (internal/pipelines): template
resolver, condition grammar, validator (cycles, per-type fields), and the
executor against a fake runtime (retries, suspension, resume-skip, vars
fold order, terminal idempotency), plus trigger-registry tests.Compatibility
- No breaking API or storage changes. One new table (
pipelinesin
workflows.db, registered as a system collection) and new, additive
endpoints/SDK namespaces. Existing workflows, triggers and cron jobs are
untouched. - Pipeline dispatch is master-only; upgrading a replica is a no-op for
pipelines. - The standalone Docker build now ships the SDK source beside the web app
(build/client), since the web app compiles against local SDK source. - JS SDK 2.3.0 publishing to vski.sh/npm is part of the release run;
external consumers need it forclient.pipelines.
What's next
Deliberately out of v1:
foreachfan-out over arrays, wait-for-completion
on spawned children (model it with asignalstep today), a public token
endpoint for external triggers, realtime status overlay on the canvas, and
a nativellmstep type. The Pipelines wiki
page documents all of it.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Version pinning: each run snapshots the definition into its
-
v0.13.0 Stable
released this
2026-10-22 14:11:52 +00:00 | 3 commits to main since this releaseVSKI v0.13.0
Light graphs: calendar-grade indexing
Light graphs landed in v0.10 as a relationship index for collections, and
the first heavy users all converged on the same ask: index events by
time. "What did this person do last week", "what changed on this date",
"how many hours accumulated on this edge" - the graph data model already
fit (nodes and edges carry labels, weights and free-form JSON properties),
but the ingest and query primitives were missing. v0.13.0 closes that gap
without growing the engine: no new tables, no migrations, one new index.Idempotent ingest:
upsert=trueIndexing pipelines re-run.
POST /graphs/:name/nodes|edges?upsert=true
(now alsograph.createNodes(data, { upsert: true })in the JS SDK)
replaces an existing id wholesale instead of erroring - the graph
equivalent ofMERGE, and the primitive every timetree ingester needs.
Upserts hitting an existing id additionally check the graph'supdateRule
(the item already passedcreateRulelike any create). The whole batch is
written in a single transaction: on failure nothing is persisted (batch
creates previously could persist a prefix of the batch).Atomic accumulation:
weightDeltaPATCH /graphs/:name/edges/:idacceptsweightDelta(mutually exclusive
withweight): the server reads the current weight under the graph's
write lock and adds to it, so concurrent increments cannot lose updates -
the classic "hours accumulated", "stock consumed" counter. Weights also
feed a new stats aggregate:GET .../statsnow reports
edgeWeightSumsper edge label (computed in SQL, where NULL weights are
distinguishable from 0) alongside a newnodeLabelshistogram.Property filters:
prop=path op valueThe JSON
propertiesmaps are no longer opaque. List endpoints accept
repeatablepropfilters -ref.collection=users,capacity>=5,
status!=draft,title~meeting,archived=null- compiled to SQLite
json_extractcomparisons with bound parameters (numeric-looking values
compare numerically,~is a substring match,nulltests for
absence). This makesproperties.refand capacity/stock metadata
first-class query targets. Nodes also acceptids=a,b,c(max 500) and
edgesource/targetaccept comma lists, so "everything touching last
week's day nodes" is one request.Node types in the index:
nodeLabelsNode labels are now interned in the in-memory topology index (alongside
edge labels and weights; ~4 bytes/node).neighbors,traverseand
subgraphacceptnodeLabels=a,bto restrict visited/returned nodes by
type - "documents linked to this day, not the people". Node label PATCHes
keep the index in sync; a SQL index onnodes(label)is created for new
graphs and backfilled idempotently for existing ones.Correctness fixes
- Edge weight PATCHes left the in-memory index stale on the master:
the index entry was rebuilt only when the label changed, and with the
pre-update weight - weighted shortest paths kept answering with the old
cost until restart. Weight and label changes now sync the index
immediately. - Shortest path ignored edge direction - it always relaxed both
adjacency lists, so directed graphs got undirected routes. The default
stays both-ways (documented behavior), butpathnow accepts
direction=out|in|both.
Calendar timetree, packaged
The Calendar Timetree recipe
documents the pattern end to end:week --includes_day--> daybackbone
nodes,record --on_day--> dayactivity edges with magnitudes on the
weight, deterministic ids so re-ingest is a no-op. The new
calendar-index example plugin automates it - subscribe it to a
collection with a date field and it maintains the tree on every
create/update/delete (record deletes cascade their edges; the calendar
backbone stays), withbackfillandweek_ofhelper functions. It uses
native upsert (hencevski-sdk >= 0.6.0) and needs read rules on the
source collection plus write rules on the target graph for its service
account (the recipe shows both).The server stays calendar-agnostic: any bucketing (sprints, fiscal
quarters) is just more backbone nodes with the same shape.SDKs
- JS/TS:
createNodes/createEdgestake an optional
{ upsert }second argument;updateEdgetakesweightDelta;
pathtakesdirection;getNodestakesidsandprop,
getEdgestakes arraysource/targetandprop;
neighbors/traverse/subgraphtakenodeLabels;GraphStatsadds
nodeLabelsandedgeWeightSums. All additive. - Python (
vski-sdkv0.6.0):create_nodes/create_edgestake
upsert=;update_edgetakesweight_delta=;get_nodestakesids=
andprop=,get_edgestakes list-valuedsource=/target=and
prop=;neighbors/traverse/subgraphtakenode_labels=;path
takesdirection=;GraphStatsaddsnodeLabelsandedgeWeightSums.
The sync wrapper mirrors everything automatically.
Tests
47_graph_test.tsgrows a "Calendar indexing primitives" step (upsert
replacement + duplicate rejection, weightDelta accumulation and
exclusivity, weight-patch freshness on weighted paths, directed path,
property/ids/multi-value filters, nodeLabels on neighbors and subgraph,
stats sums);wiki_graph_verify.tspins every new claim the wiki pages
make;graphdbunit tests coverSetEdgeWeight,SetNodeLabel,
directed paths and node-label filters. On the Python side, the SDK's
test_47_graphs.pymirrors the new surface andtest_53_calendar_index.py
exercises the calendar-index plugin end to end (tree build, re-ingest
idempotency, delete cascade,week_of,backfill). A pre-existing flaky
SchemaFromMaptest (map iteration order) is now order-independent.Compatibility
- No breaking API or storage changes; everything new is opt-in query/body
parameters. Path defaults to the historical both-ways walk; plain
POST stays insert-only. - No migrations: the node-label index is
CREATE INDEX IF NOT EXISTS,
applied on graph creation and backfilled lazily (master only) when an
existing graph's index loads. - Upsert replaces
label/properties(and edge endpoints/weight)
wholesale - it does not merge. Pipelines that relied on the
create-then-update fallback ingraph-synckeep working unchanged.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Edge weight PATCHes left the in-memory index stale on the master:
-
v0.12.0 Stable
released this
2026-10-22 00:30:07 +00:00 | 4 commits to main since this releaseVSKI v0.12.0
Plugins: supervised Python beside the server (experimental)
Every real deployment grows a system around VSKI: an embeddings indexer,
a record-to-graph sync, a crawler, a chat bot. Until now those lived as
undocumented sidecars - cron entries, hand-rolled daemons, HTTP triggers
pointing at localhost - with no shared lifecycle, no configuration story
and no observability. v0.12.0 makes that pattern first-class: plugins are
Python processes supervised by the server, configured and observed through
the admin API and dashboard, running in the same container with zero
network hops.The design stance matters as much as the feature:
- Plugins are reusable source, not packages. Think shadcn components:
you copy one into your distribution, verify its dependencies against
the rest of your environment, tune its config schema, and build one
image that just works. The server never installs or manages Python
dependencies - the official images ship no Python at all, and
distributions that want plugins bring their own locked interpreter
(the wiki has the Dockerfile snippet). - An instance is data. Plugin + database + JSON config + enabled
flag. Changing an API key or cloning a bot for another chat group is a
PATCH or a POST that restarts exactly one process - never the server,
never the container. - Not a sandbox. Plugin code ships inside your image and is trusted,
exactly like workflow workers. There is no upload endpoint; code
arrives via the filesystem only.
Plugins are experimental and disabled by default
(PLUGINS_ENABLED=trueto switch on; when off, every plugin endpoint
answers 503 with setup instructions).How it works
A plugin is a directory under
PLUGINS_DIR(default
DATA_DIR/plugins) containing avski-plugin.jsonmanifest (name,
runtime entry, config schema withsecretfields, declared deps as
metadata) and a Python package. The supervisor spawns
$PLUGIN_PYTHON -m vski.plugin.runnerinside it -PLUGIN_PYTHONmust
havevski-sdk>=0.5.0(and the plugin's dependencies) installed by the
distribution.- Control plane: protocol frames on the process's stdin/stdout
(initialize / ping / invoke / shutdown), logs on stderr. A warm
invocation is a pipe write - no socket, no cold start. - Health: pinged every 10s; an unresponsive process is killed and
restarted. Crashes restart with exponential backoff (1s doubling to
60s, reset after 5 minutes of uptime); therestartscounter tracks
crash restarts.kill -9recovery is covered by the e2e suite. - Credentials: each process start mints a fresh API key with the
pluginrole and deletes it when the process exits - key material
exists only in server memory and the child environment. The key is a
normal authenticated principal: collection and graph access rules
apply to it. - Events: plugin handlers subscribe through the existing realtime
websocket using a per-instance consumer group - at-least-once delivery
with ACK-committed group offsets, so events that occur while a plugin
is down are redelivered when it comes back (see the realtime section
below). - Replicas never supervise plugins (like webhooks and realtime);
instance status stays readable through the synced registry.
API
All admin-only:
GET /api/plugins,POST /api/plugins/scan- catalog from manifestsGET/POST /api/plugin-instances(instances are unique per
(dbName, name)),GET/PATCH/DELETE /api/plugin-instances/:name?db=- a config change bumps
configVersionand restarts only that
instance's process
- a config change bumps
POST .../:name/start|stop|restartPOST .../:name/invoke-{function, params, timeoutSeconds?}
(default 30s, max 120s) over the warm pipeGET .../:name/logs?level=&limit=- captured stderr (newest first;
kept 7 days in_plugin_logsin stats.db)
Collection triggers and HTTP cron jobs accept
plugin://<instance>/<function>as their URL: the webhook engine
dispatches it in-process with the same retry policy and the same
_webhook_logspipeline - a zero-hop alternative to pointing a trigger
at localhost. Cron jobs resolve their own database for the instance
lookup.Dashboard
Settings → Plugins: catalog cards from the manifests, the instances
table with live status chips / restart counts / uptime, a config editor
generated from each manifest's schema (saving restarts just that
process), an invoke console (function picker + params + result), a
captured-log tail, start/stop/restart actions, and a disabled-state
banner pointing at the env flags. The dashboard now depends on
@vski/sdk@^2.2.0.Writing a plugin
from vski.plugin import Plugin app = Plugin() @app.on_event("documents") # realtime consumer group, at-least-once async def on_document(ctx, event): ... @app.function("reindex") # callable via /invoke and plugin:// triggers async def reindex(ctx, params): ... @app.cron("*/5 * * * *") # dependency-free 5-field cron async def sweep(ctx): ...ctxcarries a db-scopedclient, the instanceconfigand alog
whose output the server captures. Four working examples ship in
examples/plugins/:hello(every handler type, doubles as the e2e
fixture),note-bot(the wiki tutorial plugin),embeddings-indexer
(event-driven chunking/embedding via an OpenAI-compatible endpoint -
formalizes the Embeddings tutorial's "next steps") andgraph-sync
(mirrors a collection into a light graph withproperties.reflinks -
formalizes the Graphs tutorial's sync pattern).Realtime: durable consumer groups and fixes
Work on plugin event delivery hardened the realtime gateway for
everyone:- API keys are now accepted on
/api/realtime(?auth=<key>,
any enabled key) - service processes can subscribe like any client. - Consumer groups are durable. ACKs now advance a per-group
committed offset, and consumers joining later (or plugin processes
restarting) resume after it instead of tailing from "now" - events
that fired while nobody was connected are no longer skipped. Group
deliveries now carry theackIdfield both SDKs already expected
(neither ever ACKed before, because the server never sent it). - Fixed: a fresh group consumer received one stale event from the
head of the untrimmed event store (delivery position initialized
from zero instead of the subscription position). - Fixed: event frames carried the id of the first stored event
with a matching action rather than their own, so ACKs could commit
the wrong position. - Event ids are zero-padded (
<millis>-<seq>), making the id ordering
used throughout the gateway numerically correct.
Non-group subscriptions are unchanged. Delivery remains at-least-once
for groups.Supervisor hardening
Start/stop transitions are serialized per instance (a restart can no
longer race the reconciler into double-spawning an orphaned process),
shutdown waits are bounded, and no process is spawned once shutdown has
begun.SDKs
- TypeScript/JS (
@vski/sdkv2.2.0): newclient.plugins
namespace -list,scan,listInstances,getInstance,
createInstance,updateInstance,deleteInstance,
startInstance/stopInstance/restartInstance,invoke,logs-
with full types (PluginCatalogItem,PluginInstance,
PluginRuntimeInfo,PluginLogEntry). - Python (
vski-sdkv0.5.0): thevski.pluginmodule described
above, a matchingclient.pluginsmanagement namespace, and three
transport fixes: request paths with an embedded query string were
silently dropped (httpx replaces a URL's query when givenparams,
even empty), websocket auth tokens are now URL-quoted (raw-base64
API keys containing+previously failed as spaces), and group
subscriptions tracklastIdso a reconnecting consumer resumes
after the last seen event.
Documentation & tests
Wiki: Plugins reference
(architecture, manifest spec, env contract, API, security model,
distribution Dockerfile) and a hands-on
Plugins Tutorial
whose flows are covered end-to-end by the new suites:
48_plugins_test.ts(15 steps: catalog, lifecycle, warm invoke, event
delivery, captured logs, per-instance restart on config change,
plugin://trigger delivery, clone-into-second-db, crash recovery,
admin gating, disabled-503) andtest_48_plugins.py(instance
lifecycle, event delivery,plugin://triggers, admin gating,
including downtime redelivery). The e2e harnesses now start their
servers with plugins enabled and the examples directory mounted.Compatibility
- No breaking API or storage changes. Plugins are off by default; the
plugins/plugin_instancestables (default.db) and_plugin_logs
(stats.db) are created idempotently on boot. - Realtime group consumers start ACKing and resume from committed
offsets - strictly fewer lost events; clients that manually ACKed are
unaffected. The padded event-id format only matters within a single
server process (the event store is in-memory). - Official docker images are unchanged (no Python); plugin-enabled
distributions buildFROMthem per the wiki.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Plugins are reusable source, not packages. Think shadcn components:
-
v0.11.1 Stable
released this
2026-10-21 16:19:43 +00:00 | 5 commits to main since this releaseVSKI v0.11.1
Fix: per-item access rules on graphs
Graph list and read endpoints evaluate their access rule twice: once as
a gate when the request arrives (no node or edge in hand yet), then
again per item. The gate ran the rule with no item data, so any rule
that referenced item fields - for examplelistRule: "label = 'public'"- failed evaluation at the gate and every non-admin request
errored out. The per-item pass documented in v0.10.0 (filtering,
scan-ahead,"filtered": trueresponses) never ran.The gate now defers to the per-item evaluation when a rule references
item fields:- empty rules still deny,
"*"and auth-only rules
(@request.auth.id != '') still decide at the gate, and malformed
rules still error; - single reads (
GET .../nodes/:nodeId,.../edges/:edgeId) apply
viewRuleper item, so a field-based view rule hides individual
items instead of failing the request; - list endpoints filter with the documented scan-ahead and report
"filtered": true, counting only visible items; - traversal endpoints (
neighbors,traverse,path,subgraph,
stats) remain strictly gated byviewRulewith no per-item pass,
unchanged.
The behaviour is covered end-to-end in both the Deno and Python graph
suites: a field-basedlistRule/viewRuleagainst a non-admin client,
including thefilteredflag and per-node denial.Documentation
The project wiki gained a
Graphs reference page and a
knowledge-graph tutorial -
linking collection records through a graph (works_on/authored/
depends_onedges), traversal queries, a sync pattern for record
changes, rules, and the dashboard explorer. The Collections page was
corrected along the way:selectfields take
options: { values: [...] }, not an array.Compatibility
- No API, storage or SDK changes:
@vski/sdkstays at 2.1.1,
vski-sdkat 0.4.1. - Rules that already worked behave identically; requests that
previously errored (field-referencing rules) now return filtered
results. - Full-suite runs still show the environment-flaky
gates/realtime/workflow-hook tests known from earlier releases -
failing sets vary between runs of the same binary, and they reproduce
on the committed v0.10.x baseline with clean data. The graph suite
passes consistently.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- empty rules still deny,
-
v0.11.0 Stable
released this
2026-10-21 14:55:49 +00:00 | 5 commits to main since this releaseVSKI v0.11.0
Graphs in the dashboard
v0.10.0 shipped light graphs with SDK-only access; this release adds the
follow-up web update. Graphs are now first-class in the dashboard: browse
them alongside collections, explore a subgraph interactively, and edit
nodes and edges without leaving the browser.What's in the UI
- Sidebar: a Graphs section lists every graph grouped by database,
live-updating as graphs are created or deleted. The collections-style
header links to the index page and the create form. - Index (
/graphs): all graphs across all databases with direction,
caps and creation date; delete with confirmation. - Create (
/graphs/create): name, target database, directed/undirected,
optional per-graphmaxNodes/maxEdges, and the five access-rule
fields - the same inputs the SDK accepts. - Explorer (
/graphs/:name?db=): the main page.- A stats strip (node/edge counts against effective caps with usage
bars, average and max degree) and clickable edge-label chips that
double as label filters for every query on the page. - A query bar: searchable center node, hops (1-5), result limit and
direction. The page auto-seeds from the most recent node so a graph
is never a blank screen. - The canvas renders the subgraph with Cytoscape.js and the fCoSE
layout: distinct seed node, per-label edge colors, directed arrows,
zoom/fit controls, and theme-aware colors for both light and dark. - Click a node or edge for a detail panel with properties and actions:
Center here re-seeds the view, Expand merges one-hop neighbours
into the canvas without a full reload, plus edit and delete. - A shortest-path finder: pick two nodes (same search), choose
hops or weight cost, and the path is highlighted on the canvas with
everything else dimmed. - Full node and edge CRUD from modals - including endpoints picked via
node search when creating an edge.
- A stats strip (node/edge counts against effective caps with usage
Cytoscape is lazy-loaded with the explorer page, so the main dashboard
bundle does not grow. The embedded dashboard in the standalone binary
ships the same pages.Substring search for nodes and edges
Listing endpoints only supported an exact-match
labelfilter - usable
for slicing by label, useless for finding a node in a graph that may hold
100k of them. Both list endpoints now acceptq:GET /api/graphs/:name/nodes?q=substring-matches node id and labelGET /api/graphs/:name/edges?q=substring-matches edge
id, source, target and label
qcombines with the existinglabel/source/targetfilters and
pagination, and the usual access rules still apply. The dashboard's node
pickers are backed by it (debounced, 20 results per keystroke).SDKs
- TypeScript/JS (
@vski/sdkv2.1.1):getNodes/getEdgesaccept a
qsubstring-search option. - Python (
vski-sdkv0.4.1):get_nodes/get_edgesaccept the
matchingq=keyword (mirrored by the sync wrapper). No other surface
changes.
Fixes
- Dashboard: edge-label chips moved below the canvas, next to the
legend they explain. - Dashboard: the canvas now renders correctly on both themes. daisyUI
5 serves theme colors asoklch()and Chromium preservesoklchin
computed styles, which Cytoscape's canvas cannot parse - every color is
now converted to sRGB through a canvas pixel readback, and theme flips
re-resolve node, edge and label colors live. - Dashboard: the sidebar footer is decluttered - the OpenAPI link
moved to the connection modal footer (left of Retry Connection),
leaving user actions alone in the sidebar.
Compatibility
- No breaking changes.
qis an additive query parameter ignored by
older clients; older servers ignore it from newer SDKs (results are
unfiltered). - Access rules, caps and realtime publishing behave exactly as in
v0.10.0; nothing about the storage or index changed. - Python e2e: the graph suite covers substring search; one workflow
websocket test is flaky under full-suite ordering (passes in
isolation, reproduces on the v0.10.0 backend as well - not a
regression from this release).
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Sidebar: a Graphs section lists every graph grouped by database,
-
v0.10.0 Stable
released this
2026-10-21 10:54:05 +00:00 | 6 commits to main since this releaseVSKI v0.10.0
Light Graphs: retiring the Neo4J sidecar
Most tenants that needed graph workloads ran Neo4J alongside VSKI.
Looking at actual usage, 99% of those use cases - agent graphs,
knowledge linking, dependency walking - never touched the advanced
features (Cypher, clustering, multi-hop pattern matching at scale) and
never exceeded 100k edges. What they did incur was the cost of running
and maintaining a separate database service: another deployment unit,
another backup/replication story, another source of truth to keep
consistent with VSKI data, another thing on-call has to know.Light graphs move that workload into VSKI itself. Graphs are now a
first-class entity type alongside collections - same
(dbName, name)namespacing, same access-rule model, same single
SQLite storage engine, same replication - so the graph and the content
it indexes live in one place. For the workloads tenants actually run,
this removes the Neo4J dependency and its engineering overhead
entirely; no new infrastructure ships with it.How it works
A graph is a named, db-scoped registry entry (same tombstone/revive
semantics as collections) whose data lives in two physical tables in
the tenant database:graph_<name>_nodesandgraph_<name>_edges
(labelled, directed edges with optional numeric weight, cascading
deletes on endpoints). SQLite stays the source of truth - no separate
service, nothing new to operate.Traversal performance comes from an in-memory index: on first access
each graph's topology (ids, edge labels, weights - never properties)
is loaded into a compact adjacency index with nodes and edges
interned touint32slots. A fully-warm 100k-node / 100k-edge graph
occupies ~20-25 MB and a worst-case BFS runs in single-digit
milliseconds. Mutations serialize per graph (check + SQL write +
index update under one lock), so size caps cannot be raced and the
index cannot drift from committed state. Cold graphs are evicted LRU
(GRAPH_INDEX_MAX_GRAPHS, default 32) and reload from SQLite on next
touch; replicas revalidate against the database epoch and reload
transparently.API
Registry (mirrors
/api/collections):GET/POST /api/graphs,
GET/PATCH/DELETE /api/graphs/:name(soft delete keeps data;
re-creating the same name revives the graph with its contents).Data & traversal:
GET/POST /api/graphs/:name/nodes,GET/PATCH/DELETE .../nodes/:nodeIdGET/POST /api/graphs/:name/edges,GET/PATCH/DELETE .../edges/:edgeIdGET .../nodes/:nodeId/neighbors?direction=out|in|both&labels=a,b&limit=GET .../nodes/:nodeId/traverse?depth=N&direction=&labels=&limit=(BFS, max depth 10)GET .../path?from=&to=&maxHops=&cost=hops|weight&labels=(BFS or Dijkstra over edge weights)GET .../subgraph?nodes=a,b&hops=N- neighborhood around seed nodesGET .../stats- node/edge counts, label histogram, degree stats, effective caps
Nodes and edges accept single objects or arrays on create; ids are
optional (15-char generated). Deleting a node cascades its edges.Rules
The five collection rule fields apply with collection semantics: empty
rule denies non-admins,"*"is public, admins bypass. List endpoints
evaluate rules per item (non-trivial rules scan ahead to fill the page;
the response carries"filtered": trueand counts only visible items
when that happened). Traversal endpoints are gated byviewRule.Caps
GRAPH_MAX_EDGES/GRAPH_MAX_NODES(default 100000 each) bound every
graph server-wide; a graph may declare a lower per-graph cap via
options.maxEdges/options.maxNodes. Exceeding the cap is a clean
400 (graph edge cap exceeded (...)) from the per-graph write lock.The caps exist to keep the memory story boring: every graph is bounded,
so the total index footprint is bounded by the number of warm graphs,
and nothing about the feature changes VSKI's single-binary, single-file
storage profile.SDKs
- TypeScript/JS (
@vski/sdkv2.1.0): newclient.graph(name, db?)
/client.db(name).graph(name)namespace (node/edge CRUD,
neighbors,traverse,path,subgraph,stats) and
client.settings.graphsregistry namespace. New types:
GraphConfig,GraphNode,GraphEdge,GraphStats, and the
traversal result types. - Python (
vski-sdkv0.4.0): matching surface -client.graph(name, db)/client.db(name).graph(name)(get_nodes/create_nodes/
get_node/update_node/delete_node, the same five for edges,
neighbors,traverse,path,subgraph,stats) plus the
client.settings.graphsregistry namespace andGraphConfig/
GraphNode/GraphEdge/GraphStatsTypedDicts. The sync wrapper
mirrors both (SyncVskiClient.graph(...)). The e2e harness (including
the replica suite) now honoursVSKI_BINARY/VSKI_DIRoverrides, so
it runs against any server checkout; the suite passes on pytest 8 and 9.
Fixes
- Collections: the drop index/view/FTS routes
(DELETE /api/collections/:name/indexes/:indexName,
.../views/:viewName,.../fts/:ftsIndexName) were documented and
implemented but never registered by the router - every call 404ed. They
are wired now; the SDKdrop_view/drop_index/drop_fts_index
methods work against them. - Python SDK docs:
set_viewswas documented as full-list
replacement, but the server only ever adds views - slugs missing
from the new list stay until dropped individually. The docstring now
states the additive semantics and points atdrop_view(which takes
the full materialised<collection>_view_<slug>name).
Compatibility
- No breaking changes. The
graphsregistry table and the unique index
uq_graphs_db_nameare created idempotently indefault.dbon boot;
tenant tables are created per graph on demand. - Existing Neo4J deployments are unaffected; migration to light graphs
is per-workload (create the graph, replay nodes/edges through the
SDK or a workflow) and can be done incrementally. - Graph mutations publish realtime events on the
db:<graph>topic
(subscription UI support follows in the dashboard update). - Dashboard: graph management pages are not part of this release; use
the SDK or the SQL console until the follow-up web update.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v0.9.0 Stable
released this
2026-10-17 17:27:28 +00:00 | 7 commits to main since this releaseVSKI v0.9.0
Embeddings: model tags, vector fetch, and a fixed vec0 layout
The embeddings API grows the primitives downstream ML consumers need:
provenance (which embedding model produced these vectors) and export
(the actual vectors, not just distances).Model tags
Upsert accepts an optional
modelform field. The first tagged write pins
it for the collection in the new_vec_metadatasystem table (tenant db,
next to the<collection>_vectable — deliberately not collection
options, which dashboard PATCHes can rewrite). Later upserts declaring a
different model are rejected with 409; search responses and the new
info endpoint echo the pinned tag, so a consumer can refuse embeddings from
a model it wasn't built for:curl -F recordId=$RID -F model=llama-backbone-q8 -F vector=@v.f32 \ localhost:3000/api/collections/notes/embeddingsNew endpoints
GET /api/collections/:name/embeddings—{collection, model, dimensions, metric, count}; a collection with no vectors yet reports
nulls andcount: 0.GET /api/collections/:name/embeddings/:recordId— the exact stored
vector as a JSON float array (float32 values, lossless round-trip).POST /api/collections/:name/embeddings/fetch—{"ids": [...]}→
{"vectors": [{recordId, vector}], "missing": [...]}, indexed point
lookups (max 1000 ids).
Search additionally validates the query dimension against the pinned one
(clear 400 instead of a deep vec0 error) and can be combined with
vec_to_json/hexthrough the admin SQL console as before.The vec0 layout fix (migration required)
The old virtual-table layout (
rowid INTEGER PRIMARY KEYwithrecord_id
as an unindexed metadata column) had two problems: point lookups by
record_idwere full scans, andINSERT OR REPLACEnever conflicted —
every re-upsert of the same record appended a duplicate row that then
duplicated search hits.The canonical layout is now
record_id TEXT PRIMARY KEY, which indexes
record_idin the shadow rowids table (true point queries) and makes
re-upserts replace. vec0 tables cannot beALTERed or renamed, so
BootstrapDBrebuilds legacy tables transactionally on first boot after
the upgrade — copying vectors through a temp table, deduplicating with
last-write-wins — and seeds_vec_metadata(model: null, dimensions
parsed from the old DDL,metric: "l2").Migration notes
- The rebuild copies every vector once; on multi-GB collections expect a
one-time boot proportional to table size. It runs inside a single
transaction per table and is idempotent. - Duplicate rows accumulated by old re-upserts are collapsed to the
latest vector perrecord_id. - Distance semantics are unchanged and now documented: sqlite-vec's
default euclidean L2 over unnormalized float32 vectors, ascending (the vendored sqlite-vec l2 kernel returns the sqrt — plain distance, not the square). make test-vecruns the vec0-dependent Go tests (plaingo test
compiles without thesqlite_vecextension).
Compatibility
API is additive (one new optional upsert field, three new endpoints, two
new response fields). The TS client and the Python SDK gain matching
methods. Existing clients see one behavior change: re-upserting a record
no longer duplicates it in search results — that was the bug.The vendored sqlite driver fork also gained an unconditional
#cgo LDFLAGS: -lminsqlite3.go: cgo compiles every.cfile in the
package directory, sosqlite-vec.c(and itssqrtcalls) links in every
build, not justsqlite_vec-tagged ones — untaggedgo testbinaries
failed to link on toolchains where libm is not implicit.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
2 downloads
-
v0.8.0 Stable
released this
2026-09-19 16:09:30 +00:00 | 7 commits to main since this releaseVSKI v0.8.0
vcli: the vski CLI ships as a release asset
The
vski-clirepo (submodule of platform since this release) builds
vcliand attaches it to every release from now on:curl -fsSL https://vski.sh/x/platform/releases/download/latest/vcli -o vcli chmod +x vcliTwo halves:
-
Model packs (
info/verify/inspect/import, the
pkg/pack+pkg/npyGo libraries) — unchanged tooling for the
vski.model/2opaque pack format. -
VSKI LAB management (new):
vcli lab initscaffolds a self-hosted
lab — it downloads the lab compose from the app deploy (keeping the
stack definition fresh independently of vcli releases), resolves the
api/jupyter base images to exact registry digests, and renders the lab
layout:data/ the platform's own data (multi-tenant SQLite) projects/ project data — the api's bind docker/ api + jupyter image shims (FROM the registry images) — the place to add local dependencies (GPU/rocm userland, …) .env user-editable environmentBecause the api/jupyter images BUILD from the local shims, a lab can
grow its own dependencies without forking the stack; host-side
additions (device passthrough) go in adocker-compose.override.yml.Also:
vcli lab update(refresh compose + pins, fill missing.env
keys, never touching your edited shims),vcli lab up|down|status,
andvcli lab migrate(split a pre-vclicache/datalab into
data/+projects/, dry run by default).
The release pipeline (
release.ts) initializes the submodule, stamps the
version in (vcli version), and uploads the binary alongsidevskiand
vski-standalone.Compatibility
No engine, API or storage changes — v0.8.0 adds the
vski-clisubmodule
and the release-asset pipeline. Existing standalone binaries and images
are unaffected. Thelatest/vclidownload URL goes live with this
release; the lab compose itself is served by the app deploy
(/docker-compose.lab.yml).Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.7.1 Stable
released this
2026-08-23 18:31:41 +00:00 | 9 commits to main since this releaseVSKI v0.7.1
Dashboard: database-scoped collections (web UI)
The standalone dashboard now works with the per-database collection
registry introduced in v0.7.0. Browsing a collection that lives outside
thedefaultdatabase previously failed with
{"error":"Collection not found"}— every dashboard request resolved in
default, where the collection does not exist.- Sidebar collection links carry their database
(/collections/<name>?db=<dbName>), so the same bare name in two
databases is addressable and never ambiguous. - Collection pages resolve the database from the link (falling back
to thedefaultdb when the URL is unqualified) and issue every
records operation — list, create, update, delete, bulk — through the
db-scoped SDK namespace. - Collection settings (schema/indexes/views/rules editor) and the
API Rules page PATCH the collection through its own database;
relation pickers load their options from the same db. - System collections (
_api_keys,_sql_snippets, …) keep resolving
from any namespace, unchanged.
SDK (TS/JS) v2.0.4
Published to the package registry.
client.db(name)now also exposes the
settings namespace (client.db(name).settings.collections.*) so
registry operations resolve within the named database; records namespaces
were already scoped. This is the version the dashboard ships against.Compatibility
No API or storage changes — v0.7.1 is the dashboard + SDK companion
release for the v0.7.0 per-database registry.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Sidebar collection links carry their database
-
v0.7.0 Stable
released this
2026-08-23 17:17:00 +00:00 | 11 commits to main since this releaseVSKI v0.7.0
Per-Database Collection Names
Collection names are now scoped per database instead of globally unique.
Two databases may each have anorderscollection — they are fully
independent namespaces with independent schemas, records, views, indexes,
realtime topics, and tombstones. Multi-tenant consumers no longer need to
prefix collection names to avoid collisions.Breaking Changes
- Bare-name cross-database access no longer falls back to a global
lookup. Collection names and IDs resolve strictly within the database
addressed by thex-dbnameheader (defaultdefault); a wrong or
missing header is a 404. Cross-database access must qualify the
database (client.db(name).collection(...),collection(name, db), or
thex-dbnameheader). - Registry uniqueness is now
(dbName, name)instead ofname
alone. Creating a collection that duplicates a live name in the same
database still fails withCollection name already exists; the same
name in another database is allowed. - Realtime topics are keyed
(db, name)and the connection's?db=
parameter is authoritative: SUBSCRIBE resolves the collection in that
database and a miss is a loud ERROR frame. EVENT/SUBSCRIBED frames
carry a new additivedbfield. - Duplicate relation targets must be qualified. Relation fields may
declare an optionaloptions.dbfor cross-database targets; relations
without it resolve in the source collection's own database (then a
unique global match). A name present in several databases with no
qualifier is a 400 naming the candidates.
Compatibility
- System collections (
_users,_stats_*, workflow queues) are
platform-global and keep resolving from any namespace — stats and
workflow surfaces are unchanged. - Legacy database files migrate automatically on boot: the inline
UNIQUE(name)constraint is replaced by theuq_collections_db_name
composite index via an idempotent table rebuild; rows are preserved. - Create-over-tombstone now revives the tombstoned row under its
original id instead of a blind name-keyed resurrection followed by an
error. - Options-based views in non-default databases now resolve (the options
lookup previously ran against the target file, which never holds
registry rows).
SDKs
- client (TS/JS): registry ops accept a database scope
(client.db(name).settings), realtime subscriptions re-key to the
db:nametopic and dispatch on the frame'sdb. - client-python: every
settings.collectionsmethod takes an
optionaldb; realtime dispatch filters subscription entries by the
frame'sdb.client.collection(name, db)was already scoped.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Bare-name cross-database access no longer falls back to a global