@semverse/sdk
v0.0.3
Published
TypeScript Semantic client, typed values, and Node process plugin SDK
Readme
@semverse/sdk
TypeScript types, builders, code generation, value codecs, and HTTP and file clients and Node process plugins for Semantic.
The SDK has two layers:
- An ergonomic client layer for application code:
SemanticClient, transports, file operations, package builders, and JavaScript-friendly values. - Schema tooling for package authors: complete package/query/type definitions, generated definitions for Semantic's built-in packages, and a package-to-TypeScript generator.
The main package and the programmatic generator work in modern browsers and in Node.js 20 or newer. The command-line generator and repository regeneration script are Node-only.
Contents
- Install
- Quick start
- Runtime support
- Transports
- Queries and mutations
- Commands and scopes
- Files
- Process plugins
- Values and serialization
- Defining packages
- Generating TypeScript
- Built-in generated types
- Errors
- Current limitations
- Package exports
- Development
Install
npm install --save-exact @semverse/[email protected]Node.js applications that want to run the Rust application and storage in-process
can instead add @semantic/embedded. It returns this package's SemanticClient
without starting an HTTP server; the SDK itself remains native-free and portable.
The package is ESM-only and includes its TypeScript declarations. There is no default export.
Node process plugins import their runtime from @semverse/sdk/plugin. Published
packages include the canonical import contract used by self-describing plugins.
Quick start
import {
HttpTransport,
SemanticClient,
query,
expr,
projection,
} from "@semverse/sdk";
interface Post {
title: string;
published: boolean;
views: number | bigint;
}
const client = new SemanticClient(
new HttpTransport("http://localhost:8888/api/v1/rpc"),
);
await client.insert<Post>(
"post-1",
{ title: "Hello, Semantic", published: true, views: 0 },
{ collection: "posts" },
);
const result = await client.query<Post>(
query.select("posts", {
projection: ["title", "published", "views"].map((name) =>
projection.field(expr.field(name)),
),
}),
);
if (result.kind === "select") {
for (const post of result.rows) console.log(post.title);
}Generic result types describe the object stored inside an entity or returned as a query row. They provide static checking only; the SDK does not validate server data against them at runtime.
Runtime support
The package targets ES2022 and requires:
- Node.js 20 or newer, or a modern browser.
- The Fetch API (
fetch,Headers,Request, andResponse) for HTTP and file operations. Node.js 20 provides it globally. structuredClonewhen using builders or package serialization.
The main @semverse/sdk entry point and @semverse/sdk/generator contain no
Node-only imports. Only the semantic-ts-generate CLI and the repository's
regeneration script use Node filesystem and process APIs.
Browser applications must be able to reach the Semantic server; normal browser origin, CORS, mixed-content, and credential rules still apply.
Transports
SemanticClient operates on the small RpcTransport interface. Use the built-in
HTTP transport, or provide your own implementation.
HTTP
HTTP is the simplest choice for ordinary request/response use. Headers and an
abort signal can be configured on the transport and apply to every invocation.
Each operation also accepts signal; either signal can cancel that operation.
The SDK imposes no timeout policy.
import { HttpTransport, SemanticClient } from "@semverse/sdk";
const controller = new AbortController();
const transport = new HttpTransport("https://semantic.example/api/v1/rpc", {
headers: {
authorization: `Bearer ${accessToken}`,
"x-client-name": "example-web-app",
},
signal: controller.signal,
});
const client = new SemanticClient(transport);
const pending = client.sql("SELECT * FROM notes");
// Cancels this and any other in-flight request using this transport. Once the
// signal is aborted, construct a new transport before making more requests.
controller.abort();
await pending.catch(() => undefined);You can inject a Fetch-compatible function with the fetch option, which is useful
for testing or centrally adding credentials and observability.
Custom transports
import {
SemanticClient,
type RpcTransport,
type SemanticValue,
} from "@semverse/sdk";
const transport: RpcTransport = {
async invoke(
command: string,
payload: SemanticValue,
): Promise<SemanticValue | undefined> {
// Send command and payload through an application-specific channel.
return undefined;
},
};
const client = new SemanticClient(transport);Custom transports receive decoded SemanticValues. The built-in transports handle
RPC envelopes and tagged-value encoding themselves.
Queries and mutations
Construct a typed AST directly and send it through client.query(). The server executes
it without converting it to SQL or parsing text. ASTs cover selects, joins, aggregates,
subqueries, inserts, updates, deletes, and every supported DDL operation.
import {
query,
expr,
projection,
orderBy,
assignment,
fieldPath,
} from "@semverse/sdk";
const byOwner = query.select("notes", {
predicate: expr.eq(expr.field("owner"), expr.parameter("owner")),
projection: [
projection.field(expr.field("title")),
projection.wildcard("details"),
],
order_by: [orderBy(expr.field("created_at"), "desc")],
limit: expr.literal(20),
});
await client.query(byOwner, {
params: { owner: "alice" },
scopeId: "workspace-1",
});
await client.query(byOwner, {
params: { owner: "bob" },
scopeId: "workspace-1",
});
await client.query(
query.insert("notes", [{ id: "note-1", title: "New note" }]),
);
await client.query(
query.update(
"notes",
[assignment(fieldPath("title"), expr.parameter("title"))],
{ predicate: expr.eq(expr.field("id"), expr.literal("note-1")) },
),
{
params: { title: "Updated note" },
},
);
await client.query(
query.ddl({
upsert_collection: {
name: "notes",
kind: "untyped",
integrity_mode: "permissive",
},
}),
);Builders are optional; ordinary objects matching the exported Query, Expr and
DdlOperation types use the same representation. Query fields with schema defaults
can be omitted. Field paths contain explicit segments: expr.field("profile", "name")
selects a nested field, while expr.field("profile.name") names a single field.
The complete transitive DDL schema is exported under QuerySchema and @semverse/sdk/query.
AST parameters stay unbound and bindings are supplied separately. Names are
case-sensitive ASCII identifiers without a leading colon; repeated names reuse the
same typed value. Missing, unused or invalid bindings return RpcError with code
query_parameter_error and data.reason (plus data.name when applicable).
Bindings use the tagged value codec, including UUIDs, bytes and wide integers.
SQL parsing fallback
When the starting input is SQL text, parseSql() returns a reusable AST without
executing it. The selected scope determines the SQL dialect. Parameters remain
unbound; values are provided when the returned AST is executed.
const parsed = await client.parseSql(
"SELECT id FROM notes WHERE owner = :owner",
{
scopeId: "workspace-1",
},
);
await client.query(parsed, {
params: { owner: "alice" },
scopeId: "workspace-1",
});Parsing uses exact value decoding so literal widths and scalar kinds survive
resubmission. Numeric and other typed scalar fields can be TaggedScalar wrappers;
string, boolean and null fields retain their ordinary JavaScript representation.
Void remains an explicit wrapper, including inside literal objects. Both
client.parseSql() and client.invoke(commands.parseSql, ...) select exact decoding.
Custom transports must honor InvokeOptions.valueDecoding for the same guarantee.
SQL and PRQL
sql() and prql() remain available for text queries. All accept an optional
scopeId; SQL accepts the same separate parameter bindings as AST queries. A
format option is only valid when query() receives text.
const sqlResult = await client.sql<{ id: string; title: string }>(
"SELECT id, title FROM notes",
{ scopeId: "workspace-1" },
);
const prqlResult = await client.prql<{ title: string }>(
"from notes | select {title}",
);
const sameAsSql = await client.query("SELECT * FROM notes", {
format: "sql",
});QueryResult<T> is a discriminated union. Check kind before accessing
operation-specific fields such as rows, returning, inserted, or deleted.
Counts may be bigint when they exceed JavaScript's safe integer range.
Get, insert, and delete
interface Note {
title: string;
note_content: string;
note_format: "text" | "markdown";
}
await client.insert<Note>(
"note-1",
{
title: "SDK notes",
note_content: "Typed from end to end",
note_format: "markdown",
},
{ collection: "notes", scopeId: "workspace-1" },
);
const entity = await client.get<Note>("note-1", {
collection: "notes",
scopeId: "workspace-1",
});
if (entity) console.log(entity.id, entity.collection, entity.object.title);
await client.delete("note-1", {
collection: "notes",
scopeId: "workspace-1",
});The collection is optional for these methods and is omitted from the RPC payload when not supplied.
Batch operations
BatchBuilder takes snapshots of supplied objects and of the completed operation
list, so later mutation of inputs does not modify an already built batch.
import { BatchBuilder } from "@semverse/sdk";
const operations = new BatchBuilder()
.upsert("note-1", { title: "First" }, "notes")
.upsert("note-2", { title: "Second" }, "notes")
.delete("obsolete-note", "notes")
.deleteMany(["draft-1", "draft-2"], "notes")
.build();
const outcome = await client.batch(operations, { scopeId: "workspace-1" });
console.log(outcome.stats.upserted, outcome.stats.deleted);By default the result contains { dataset, stats }. Select a compact response
with returning; its literal value determines the TypeScript result type:
const { stats } = await client.batch(operations, { returning: "stats" });
const { changes } = await client.batch(operations, { returning: "changes" });
const { rows } = await client.batch(operations, {
returning: { projection: { fields: ["title"] } },
});changes contains { collection, id, kind: "upsert" | "delete" }, sorted by
collection and ID. It describes net committed changes, so unchanged rows and
create-then-delete sequences are absent. Stats retain operation counts. Projection
also includes changes and returns { collection, id, object } for surviving changed
rows, with only selected canonical attribute keys; absent values are omitted.
An empty field list returns identities with empty objects. Unknown/ambiguous fields,
duplicate fields (including aliases), and invalid modes fail before commit with
batch_return_error and data.reason (unknown_field, duplicate_field, or
unknown_mode). Explicit "dataset" retains the default shape.
commands.batch keeps its dataset-only contract; commands.batchReturning accepts
all modes and returns the reply union.
The remotely callable BatchOperation deliberately includes only the operations
accepted by the current application RPC command: create, upsert, delete by ID, and delete
by multiple IDs. The reflected core schema also describes query-based update and
delete variants, but SemanticClient.batch() does not expose them as supported
remote operations.
Commands and scopes
command<P, O>() creates a typed command descriptor. It has no runtime schema or
validation; it associates a command name with compile-time payload and output types.
import { command } from "@semverse/sdk";
const greet = command<{ name: string }, { message: string }>("example.greet");
const response = await client.invoke(greet, { name: "Ada" });
console.log(response.message);Generated package commands use the same descriptors. The main entry point also
exports descriptors for the built-in server commands as commands.
import { commands } from "@semverse/sdk";
const scope = await client.invoke(commands.scopeOpen, {
uri: scopeUri,
mode: "auto_create",
set_current: false,
});
const notes = await client.sql("SELECT * FROM notes", {
scopeId: scope.scope_id,
});Explicit scopeId options are available on all high-level database methods,
package upserts, and file operations. The commands object additionally exposes
scopeOpen, scopeUse, scopeCurrent, and scopeList for lifecycle management.
Which scope URI schemes and visibility modes are usable depends on the server.
Files
Uploads are create-only: an existing explicit or automatic hash identity returns
RpcError with code file_already_exists. Native bytes use immutable generated
hash locators; custom upload locators are rejected, while existing locators remain
readable. The separate importer publication operation retains upsert behavior.
Use await files.delete(id, { scopeId, signal }) for explicit native deletion.
It succeeds if already absent and returns file_referenced when an enforced
reference survives. Removing a domain file link does not delete the native file.
Deletion atomically records cleanup intent with metadata removal. Bytes are
currently retained: destructive cleanup is disabled until physical store
ownership across scopes and a retention policy can be guaranteed.
File transfer uses a separate HTTP endpoint and FileClient. Use
createHttpClient to share Fetch, base headers, credentials, and a connection
abort signal across RPC, upload, and download. Protocol headers for each operation
override base headers. Set fileEndpoint if the file endpoint cannot be derived.
import { createHttpClient } from "@semverse/sdk";
const rpcEndpoint = "https://semantic.example/api/v1/rpc";
const { client, files } = createHttpClient(rpcEndpoint, {
headers: { authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const uploaded = await files.upload({
content: new Blob(["Hello from Semantic\n"], { type: "text/plain" }),
filename: "hello.txt",
mimeType: "text/plain",
scopeId: "workspace-1",
entity: { title: "Greeting", description: "Created by the SDK" },
onProgress(uploadedBytes, totalBytes) {
console.log(uploadedBytes, totalBytes);
},
});
const complete = await files.read(uploaded.id, { scopeId: "workspace-1" });
const firstKilobyte = await files.read(uploaded.id, {
scopeId: "workspace-1",
offset: 0,
size: 1024,
});
const downloadUrl = files.url(uploaded.id, "workspace-1");
// Bounded-memory consumption; cancellation also releases the response body.
const controller = new AbortController();
const { stream, contentLength, totalSize } = await files.readStream(
uploaded.id,
{
scopeId: "workspace-1",
signal: controller.signal,
},
);
await stream.pipeTo(destination);content accepts any Fetch BodyInit, including Blob, Uint8Array, and
ReadableStream. For a stream, the SDK sets duplex: "half", as required by
Node's native Fetch implementation and accepted by supporting browsers.
onProgress currently reports upload lifecycle boundaries (start and completion),
not incremental network byte progress. read buffers the entire response;
readStream returns a ReadableStream<Uint8Array> with status and optional
content type, content length, content range, and total size. A zero-sized read
returns empty content without a request (synthetic stream status 200); 416
also returns empty content. Ranged reads require a valid 206 response and
Content-Range. Headers are validated before exposing bytes and declared byte
counts are checked at EOF; malformed or truncated responses raise TransportError.
Cancel the stream, its reader, or an associated signal when abandoning a download.
Signal cancellation rejects with AbortError.
Existing standalone constructors and Fetch wrappers remain supported:
const authenticatedFetch: typeof fetch = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${accessToken}`);
return fetch(input, { ...init, headers });
};
const files = new FileClient(
"https://semantic.example/api/v1/file",
authenticatedFetch,
);Process plugins
The self-describing plugin API below is unreleased and requires a matching Semantic server checkout.
@semverse/sdk/plugin is the Node-only adapter for semantic.import/v1 over
Content-Length framed stdio (semantic.plugin.v1, interface protocol v2).
Keep stdout reserved for the protocol; write diagnostics to stderr.
import { definePlugin, importExports, servePlugin } from "@semverse/sdk/plugin";
import { packageDefinition } from "./package.js";
const plugin = definePlugin({
package: packageDefinition,
exports: {
source: importExports.source({ describe: sourceDescription, probe }),
fetcher: importExports.fetcher({ source: "source", fetch }),
},
configure(configuration, { signal, host }) {
return parseConfiguration(configuration);
},
});
await servePlugin(plugin);The package is an ordinary Package (exported from @semverse/sdk/core and
@semverse/sdk/plugin) with a required name and version. The SDK attaches the
semantic.plugin annotation, pins foreign import-interface fingerprints, and
answers protocol Describe before the live handshake. No command-line discovery
or activation descriptor is used.
semantic api plugin describe --stdio node -- /absolute/path/to/dist/plugin.js
semantic api plugin install --stdio node --config config.json -- /absolute/path/to/dist/plugin.js
semantic api plugin listUse implement({package, module, name}, handlers) for an interface defined in the
plugin package. Its methods receive positional arguments and a context containing
configuration, signal and host. The host fills own-package fingerprints.
Annotate interface method metadata with semantic.command to expose a method
through semantic.plugin.invoke or semantic.plugin.invoke_stream. Existing
command grants authorize the virtual package.export.method name.
host.call(command, payload) invokes a unary host command;
host.stream(command, payload, input?) consumes a host stream. Both run as the
plugin service account without caller delegation. Permission requests are
metadata and are never automatically granted.
A configuration TypeDef can be named with configuration: "Configuration".
configure(configuration, {signal, host}) runs before readiness and its return
value becomes context.configuration. Use dispose(configuration) for
process-owned resources after all handlers and streams finish cleanup.
Source.describe can also be a function receiving context. A Fetcher implements
fetch(request, context). An Importer implements both
importSource(request, context) and importFetched(request, content, context);
each operation returns an async iterable of ContentEvent (optionally through a
Promise). Each Fetcher/Importer names its describing Source through source.
Multiple named exports and multiple Sources are supported.
Source.probe(request, operation, context) may return
{ status: "supported", options_class: "my:plugin:ImportOptions" }. Install that
class and its attributes in the plugin package's migrations. The selection may
depend on the URL and operation, for example different settings for a video and
a channel. The import UI renders the class's normal generated form, and the host
validates its value before forwarding it as request.options. Quick import uses
valid form defaults; required fields without defaults open the review form.
Form values are keyed by attribute ID; accept those keys alongside any plain
option names your API callers use.
request.skip_contents asks for metadata only: omit media contents such as
downloads. The host stores the flag on imported items and forwards it again when
an item is re-imported. Entities and file_start events may name a source, the
URL that re-imports that item alone; the host stores it as source_url (the
request URL otherwise) next to imported_by and imported_at. Strict classes
accept this provenance by extending semantic:import:imported.
Content is serial: yield an entity, or file_start, zero or more file_bytes
containing Uint8Array, then file_end. File metadata uses key, mime_type,
optional filename, optional expected_size (safe integer or bigint), and
optional attributes. The SDK checks file order/size and automatically emits a
terminal {items, bytes} summary. Do not yield the summary yourself. Incoming
content is an async iterable with summary: Promise<ContentSummary>; consume it
to completion before awaiting the summary. Scalar attributes retain exact tags
through transformations, including UUIDs and wide integers. Configuration and
request options also preserve scalar tags; strings, booleans and null unwrap.
Use the existing value constructors for explicitly typed attributes and
decodeTagged(encodeTagged(v)) when convenient scalar decoding is needed.
stableEntityId(namespace, source, key) matches Rust's length-delimited UTF-8
hash and UUID bits. It is the default host identity when attributes.id is absent.
On hosts supporting explicit import IDs, set a nonempty string attributes.id
to reuse upstream identities across source URLs, and use those same ID strings
for references. IDs remain subject to ordinary caller permissions and cannot
reuse a visible entity of a different class. sourceProvenance returns the three
ordinary semantic:import:source_* attributes; merge them into proposals where
needed. Entity identity is independent of plugin revision and file content.
The host uploads completed files through its regular file service, then commits
entity proposals and file parent updates in one DB batch after the stream
succeeds. Thus a post can reference its file in contents while the file refers
back to the post in parent. Failure discards pending entity/parent changes;
completed file uploads, including replacements, remain. File bytes are streamed,
while entity metadata is retained until the final batch.
Set primary: true on the main entity proposal to choose the full card shown
after import (for example the post for a video, or the account for a channel).
Only one distinct proposal key may be primary. A successful import retains
{items, bytes, main_entity?, entities} on its job, using host-resolved entity
IDs. semantic.jobs.watch is a streaming command taking {id, scope_id?}; it
replays the current job, emits persisted status updates, and ends with the final
job record on success, failure, or cancellation. Reconnect to this same command
to resume observation without submitting another import.
The adapter advances output iterators only under host credit (maximum 64 events),
requests incoming content one event at a time, and respects writable
backpressure. It supports same-session idle-stream forwarding. Call and stream
cancellation abort context.signal; wrap acquired resources in try/finally
and pass the signal to network requests. Cleanup must cooperate with cancellation:
shutdown waits for in-flight handlers and iterator cleanup before disposal and
shutdown_ack. EOF and protocol failures also abort and clean up work.
servePlugin accepts optional input, output, signal, and maxFrameBytes
(default 64 MiB). Yield file bytes in small chunks; the frame limit is not a total
file size limit. This adapter implements the import interfaces; arbitrary host
callbacks and WebSocket serving are outside its API.
Values and serialization
SemanticValue
SemanticValue is the JavaScript-facing value model used by entities and custom
commands. It includes ordinary primitives, bigint, Uint8Array, Date, arrays,
objects, Map, Semantic variants, and explicit tagged values.
The automatic RPC mapping includes:
| JavaScript value | RPC value |
| ---------------------------------------- | -------------------------------- |
| undefined, null, boolean, string | void, null, bool, string |
| Integer number or bigint | i64 by default |
| Non-integer number | f64 |
| Uint8Array | bytes |
| Date | nanosecond date_time |
| Array, Map, plain object | list, map, object |
| { $variant, value, type? } | variant |
Decoded integers are number when safely representable and bigint otherwise.
A decoded date_time is its numeric nanosecond representation; decoding does not
automatically construct a Date.
Exact scalar tags
JavaScript cannot distinguish a UUID from a string or u8 from i64. Use the
value helpers when the exact Semantic scalar kind matters:
import { decodeTaggedExact, encodeTagged, value } from "@semverse/sdk";
const payload = {
id: value.uuid("0f8fad5b-d9cb-469f-a165-70867728950e"),
priority: value.int("u8", 7),
elapsed: value.durationMs(250),
state: value.variant("published", null, "PostState"),
};
const tagged = encodeTagged(payload);
const exact = decodeTaggedExact(tagged);encodeTagged() and the built-in transports recognize values produced by these
helpers. Integer helpers validate both the declared width and JavaScript safe-number
rules. decodeTaggedExact() recursively preserves scalar tags as explicit wrappers;
ordinary decodeTagged() returns convenient JavaScript scalars.
Three JSON domains
Similar-looking JSON appears in three different wire contexts. Use the matching codec rather than applying one representation everywhere.
| Codec | Use |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| rpcJson / stringifyJson / parseJson | Lossless JSON tokens in RPC envelopes. The transport separately applies tagged-value encoding. |
| facetValueJson | A standalone externally tagged Semantic value. |
| packageJson | Facet package documents; it tags only schema locations that contain Semantic values, such as defaults, examples, expressions, and migration objects. |
| flatJson | Ordinary untagged JSON used for file entity metadata. |
import {
facetValueJson,
flatJson,
packageJson,
parseJson,
stringifyJson,
} from "@semverse/sdk";
const exactInteger = stringifyJson({ sequence: 9_007_199_254_740_993n });
const parsed = parseJson(exactInteger); // unsafe integer tokens become bigint
const valueDocument = facetValueJson.stringify(value.uuid(uuid));
const packageDocument = packageJson.stringify(pkg);
const metadataDocument = flatJson.stringify({
title: "Photo",
taken: new Date(),
});stringifyJson() emits bigint as an exact JSON number token rather than a quoted
string. flatJson turns Date into an ISO string and bytes into number arrays; it
rejects explicit tagged wrappers because they have no flat representation.
Defining packages
The exported interfaces let applications construct a package directly. Builders fill repetitive container/default fields, reject duplicate definitions, clone inputs, and return detached snapshots. They do not infer schema IDs or validate a package against a server catalog.
This example defines a type, attribute, class, contract command, secondary module, DDL migration, and seed entity:
import {
PackageBuilder,
emptyMeta,
type Package,
type TypeNode,
} from "@semverse/sdk";
const stringType: TypeNode = {
kind: { string: { format: null, normalization: null } },
constraints: [],
annotations: [],
};
const pkg: Package = new PackageBuilder("acme.blog", "blog")
.version({ major: 1, minor: 0, patch: 0, pre: null, build: null })
.meta({ title: "Acme Blog", description: "Blog schema and commands" })
.root((module) => {
module.type({
name: "Slug",
module: null,
params: [],
ty: stringType,
visibility: "public",
meta: emptyMeta(),
});
module.attribute("title", {
id: "acme.blog/title",
name: "title",
ty: stringType,
constraints: [],
meta: emptyMeta(),
});
module.class("Article", {
id: "acme.blog/Article",
name: "Article",
inherits: null,
extends: [],
"semantic:class:strict_schema": true,
attributes: {
title: {
attribute: { id: "acme.blog/title" },
required: true,
ui_order: 0,
computed: null,
constraints: [],
meta: emptyMeta(),
},
},
constraints: [],
meta: emptyMeta(),
});
module.contract("api", (contract) => {
contract.command("acme.blog.article.get", {
params: [{ name: "slug", ty: stringType }],
results: [stringType],
throws: null,
async_fn: true,
});
});
})
.module("admin", (module) => {
module.meta({ description: "Administrative definitions" });
})
.migration("blog", "0001-create-articles", (migration) => {
migration
.description("Create and seed the article collection")
.operation({
ddl: {
upsert_collection: {
name: "articles",
kind: "schema",
integrity_mode: "strict_registered_schema",
},
},
})
.insert("articles", "welcome", { title: "Welcome" });
})
.build();
await client.upsertPackage(pkg, { scopeId: "workspace-1" });root() and module() configure a ModuleBuilder; contract() configures a
ContractBuilder; and migration() configures a MigrationBuilder. Lower-level
types such as TypeNode, TypeDef, AttributeType, ClassType, FunctionType,
MigrationOperation, and query AST types are all exported for package authors who
need more control.
Generating TypeScript
The generator converts a Package into declarations for its modules, contracts,
types, attributes, classes, interfaces, and commands. It resolves lexical and
qualified references, supports generics, avoids TypeScript identifier collisions,
and emits class references as entity IDs (string) rather than nested objects.
CLI
semantic-ts-generate package.json src/generated/acme-blog.tsWhen working in this repository:
npm run generate:package -- package.json src/generated/acme-blog.tsThe input must be a JSON representation of Package; packageJson.stringify(pkg)
produces the appropriate Facet package representation. The CLI parses integers
losslessly and formats the generated TypeScript with Prettier.
A generated module exports:
packageName, a string literal containing the package name.- Runtime constants for attribute and class IDs.
- Type aliases for package declarations and classes.
- Typed command descriptors ready for
client.invoke().
Generated class fields use their canonical attribute IDs through those constants, so the declared object shape matches database records exactly.
Names that are not valid TypeScript identifiers are sanitized. If two declarations would share a name, the generator qualifies or suffixes them; use the symbols in the generated output rather than predicting their spelling.
Programmatic API
The browser-safe generator accepts an in-memory package:
import { packageModel, renderPackage } from "@semverse/sdk/generator";
const model = packageModel(pkg);
const typescriptSource = renderPackage(model);packageModel() produces the normalized PackageModel intermediate form;
renderPackage() renders that model. The programmatic renderer does not write a
file or run Prettier.
Built-in generated types
Canonical definitions generated from the Rust source of truth are checked in with the SDK.
import {
ATTR_PERSON_DISPLAY_NAME,
ATTR_NOTE_NOTE_CONTENT,
ATTR_NOTE_NOTE_FORMAT,
type Person,
type Note,
} from "@semverse/sdk/generated/base";
import {
ATTR_FILE_FILENAME,
ATTR_FILE_MIME_TYPE,
type File as SemanticFile,
} from "@semverse/sdk/generated/filestore";
const person: Person = { [ATTR_PERSON_DISPLAY_NAME]: "Ada Lovelace" };
const note: Note = {
[ATTR_NOTE_NOTE_CONTENT]: "Generated types are ordinary TypeScript types.",
[ATTR_NOTE_NOTE_FORMAT]: "text",
};
const file: SemanticFile = {
[ATTR_FILE_FILENAME]: "notes.txt",
[ATTR_FILE_MIME_TYPE]: "text/plain",
};The main entry point also exposes the exact Rust-reflected core wire schema under a
namespace, while the core subpath provides direct imports:
import { CoreSchema } from "@semverse/sdk";
import {
ATTR_TITLE,
type Package as ExactPackage,
type expression_Expr,
} from "@semverse/sdk/core";
declare const reflected: CoreSchema.Package;
const exact: ExactPackage = reflected;
declare const expression: expression_Expr;
const titleAttribute = ATTR_TITLE;
const sameTitleAttribute = CoreSchema.ATTR_TITLE;Query types in the main entry point are aliases of the generated Semantic query
package, rather than a separate hand-maintained model. Package generation resolves
{ named: { name, args } } as a type reference; { ref: ... } represents an entity
ID and generates string. Existing package definitions that used ref for named
schema types should use named. Generated variants honor their schema tagging
strategy; externally tagged unit cases are strings and payload cases are single-key
objects. Exact scalar wrappers are included in generated scalar field types.
Use the main entry point's ergonomic types for normal client/package authoring and the reflected core types when exact correspondence with Rust's full schema is important. Some reflected types include server-internal variants that the ergonomic client intentionally does not accept.
Errors
import { ProtocolError, RpcError, TransportError } from "@semverse/sdk";
try {
await client.sql("SELECT * FROM missing_collection");
} catch (error) {
if (error instanceof RpcError) {
console.error(error.code, error.message, error.data);
} else if (error instanceof TransportError) {
console.error(error.status, error.message, error.cause);
} else if (error instanceof ProtocolError) {
console.error("Malformed RPC response", error.cause);
} else {
throw error;
}
}RpcErrorrepresents a structured error returned by the Semantic command and exposescodeand optional decodeddata.TransportErrorrepresents Fetch failures, non-success HTTP statuses, and file transfer errors. HTTP status is available when applicable.ProtocolErrorrepresents malformed or inconsistent RPC responses.- All three extend
SemanticError, which extendsError.
Current limitations
- Remote batches support create, upsert, and ID-based deletion only.
- Types and generated command descriptors are compile-time contracts; the SDK does not perform runtime schema validation.
- The SDK is ESM-only.
Package exports
| Import | Contents |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| @semverse/sdk | Ergonomic types, codecs, errors, commands, transports, client, builders, files, built-in command descriptors, and CoreSchema/QuerySchema. |
| @semverse/sdk/plugin | Node stdio import plugins, descriptors, content types, cancellation, stable IDs, and provenance. |
| @semverse/sdk/query | Canonical AST and transitive DDL schema generated from the Semantic query package. |
| @semverse/sdk/core | Direct exports of the complete Rust-reflected core schema. |
| @semverse/sdk/generator | Browser-safe package model and rendering API. |
| @semverse/sdk/generated/base | Generated types for semantic.base. |
| @semverse/sdk/generated/filestore | Generated types for semantic.filestore. |
| @semverse/sdk/generated/commands | Built-in typed command descriptors; these are also exported as commands from the main entry point. |
The wildcard @semverse/sdk/generated/* export also makes additional checked-in
generated modules importable by subpath.
Development
From lib/js/sdk:
npm ci
npm run build
npm test
npx prettier --check .The checked-in core.ts, base.ts, and filestore.ts files are generated from
Rust reflection and canonical package values through the semantic_sdk_export
crate:
npm run generate # rewrite generated files
npm run generate:check # fail if checked-in files are staleDo not edit those generated files directly. generate requires a working Rust
workspace in addition to Node dependencies.
Publishing
Build and inspect the distributable from this directory, then publish that exact tarball to npm:
npm ci
npm test
npm pack --dry-run
npm pack --pack-destination /tmp
npm publish /tmp/semverse-sdk-0.0.3.tgz --access publicPacking builds the SDK automatically. The archive contains ESM entry points, TypeScript declarations, the generator CLI, and the plugin import contract; repository tests and the Rust-dependent regeneration script are excluded.
The plugin adapter includes a real Rust-host interoperability test. After building
the SDK, run from the workspace root (use nix develop --command when Nix is
available):
cargo test --quiet --message-format=short -p semantic_import --test typescript_sdk
cargo test --quiet --message-format=short -p semantic_import --test typescript_sdk -- --ignoredThe first command compares checked-in fingerprints and Unicode stable-ID vectors
to current Rust implementations. To intentionally regenerate the contract, set
UPDATE_TS_PLUGIN_CONTRACT=1 for that command and rebuild the SDK. The ignored
integration test requires Node and built SDK output; it launches the Node fixture
with the production Rust stdio provider, applies canonical interface conformance,
and checks Source/Fetcher/Importer, both stream directions, forwarding, typed
values, references/provenance, summaries, cancellation and cleanup.
