Skip to content

Latest commit

 

History

History
282 lines (237 loc) · 16.1 KB

File metadata and controls

282 lines (237 loc) · 16.1 KB

The Node/TypeScript client API

This page covers what @openmbee/opensysml exports, how its two entry points differ, and where its surface stops. To choose between the clients, see client libraries; for a task-oriented walkthrough, see guide chapter 9. The client's own notes on packaging and its conformance run are in client/node/README.md.

npm install @openmbee/opensysml

The package is published on npm. From a checkout, build it with npm install && npm run build in client/node.

The two entry points

import reaches private child service
@openmbee/opensysml Node, over Connect with protobuf bodies yes, by default
@openmbee/opensysml/browser a page, over fetch (@connectrpc/connect-web) no — a browser spawns nothing

Both re-export the isomorphic core; the browser entry point requires an address, since there is nothing to fall back to.

Opening a connection

import { connect, load, loads } from "@openmbee/opensysml";

await using connection = await connect();          // private child of this process
const model = await connection.loads("package Demo { part def Car; }");

load(path) and loads(source) are the one-shot forms: each opens a connection of its own and closes it with the model. connect() is the longer-lived form, and every model parsed over it shares one service and one parse cache. Connection and Model both implement Symbol.asyncDispose, so await using closes them; close() is the explicit form and is safe to call twice.

ConnectOptions extends TransportOptions:

option effect
address host:port or a URL of a service to use; without it, a private child is started
encoding "protobuf" (default) or "json"; JSON costs ~6x the service's CPU on large answers
protocol "connect" (default) or "grpc"; { protocol: "grpc", encoding: "json" } is refused, not downgraded
timeoutMs deadline applied to every call the connection makes
headers extra headers sent with every call
onResponse called with each response, for logging, metrics or a conformance runner
version the version the service must report, else connecting fails with StaleServiceError
requireCapabilities capabilities the service must advertise, else connecting fails

$OPENSYSML_SERVICE=host:port is the environment form of address. A connection to a service this client did not start is only disconnected from on close(), never stopped.

Reading a model

const model = await connection.load("model.sysml");
model.hash;                                  // what the service holds it under
model.diagnostics;                           // in the order the service reported them
model.hasErrors;                             // any error-severity diagnostic

const car = await model.symbol("Demo::Car"); // short name, FQN or id
await car.children();                        // its members, one call each
for await (const symbol of model.walk()) {}  // breadth-first from the root

const value = await model.eval("2 + 2");                       // SysMLValue
const mass = await model.eval("mass", { subject: "Demo::sedan" });
const tree = await model.instantiate("Demo::Car");
tree.get("wheels");                          // a FeatureValue of the root object
tree.byId(id);                               // any object the instantiation produced

connection.model(hash) adopts a model the service already holds, which lets a hash pass between processes; an adopted model has no root symbol, so symbols are looked up by qualified name, and the service answers NOT_FOUND once it has evicted the model. symbol() searches breadth-first when given a short name and raises SymbolNotFoundError naming near misses for any name the model has not got; symbolById() is the single call for a name the service can resolve directly, reporting a miss with no suggestions.

ParseOptions are language ("sysml" or "kerml", for inline content) and strictConformance; both are capability-gated, and the client checks before it calls. strict remains as a deprecated alias of strictConformance; passing both with different values is refused. To raise on parse errors, call model.raiseForErrors() on the returned model.

Values are discriminated unions

Every oneof the service answers with arrives as a union to switch on, rather than a message with optional fields:

switch (value.kind) {
  case "int":      value.value;                  // bigint, never lossy, beyond int64 too
  case "real":     value.value;                  // number
  case "complex":  value.value.real; value.value.imaginary;  // one value, not two floats
  case "boolean":
  case "string":   value.value;
  case "quantity": value.magnitude; value.unit;
  case "measurementRef": value.unit; value.unitTerm; value.unitId;  // a bare unit and its reduction
  case "array":    value.dimensions; value.elements;   // row-major SysMLValue[]
  case "vector":   value.components;             // Magnitude[]: int | real, kept apart
  case "vectorQuantity": value.components;       // QuantityValue[], one unit each
  case "set":      value.elements;               // SysMLValue[], each once, unordered
  case "tensorQuantity": value.dimensions; value.components;  // any rank, row-major QuantityValue[]
  case "function": value.calcId; value.selfId;  // a calc held as a value; selfId when read off an object
  case "metaobject": value.elementId; value.metaclassId;  // x meta KerML::Feature: the element reflected on
  case "enum":     value.value.name; value.value.value;  // literal/enumeration ids, and a `high = 3` literal's scalar
  case "instance": value.id;                     // an object in the same tree
  case "sequence": value.elements;               // SysMLValue[]
  case "undetermined": value.reason; value.countLower; value.countUpper;  // left open by the model
  case "infinity": break;                        // the unbounded `*`
  case "null":     value.reason;                 // evaluated, no value
  case "unset":    break;                        // declared, never given one
  case "absent":   break;                        // the service sent no value at all
}

unset, undetermined and absent are distinct on purpose: the first is a feature the model leaves without a value, the second an answer the model leaves open (read, never sent), the third a field the answer did not carry. SysMLVerdict (holds / fails / undecided) and FeatureValue (single / many / error) are unions of the same shape; every verdict arm carries a standing (engine, strength, bounds, plus reported and reached — the bounds the engine stopped at), empty from a service without the engines capability; CalcResult, AnalysisResult, Validation and SweepTable read the same fields through engine, strength and bounds getters. Integers are bigint, because the service's int64 does not fit a number and an exact comparison would otherwise be a lie. decodeValue, decodeVerdict, decodeStanding and formatValue are exported for a caller decoding a response it obtained itself.

Errors

Everything derives from OpenSysMLError, so the family can be caught without knowing its members.

error what happened
ServiceError the service could not be reached, started, or answered nothing usable
ServiceUnavailableError the service was unreachable, refused the stream, or died before answering; in a browser also a fetch that never answered (dead address, CORS refusal, mid-call loss), which arrives as UNKNOWN
ServiceStartError a private child failed to start, or died while it was needed
StaleServiceError the running service reports another version than version asked for
ClosedConnectionError the connection was closed and cannot be used again
ParseError a file could not be read, or its content did not parse; carries diagnostics
EvaluationError the call succeeded and the answer reports a model failure
ExecutionError an execution the service ran failed; carries diagnostics
WrongKindError a verification or analysis named a symbol of another kind
AnalysisRunError an analysis run failed before it could report
ConversionError the service could not write the notation asked for
MigrationError the service could not read the SysML v1 model, so nothing of it was migrated
UnsupportedValueError the service sent a value this version of the client cannot decode
QueryError / DocumentQueryError a Query or runDocumentQuery failed in-band
EditError an edit was refused; subclasses (NoEditsError, EditTargetError, InvalidEditError, IllegalMemberKindError, RenameReferencedError, OverlappingEditsError, EditResultError, OwnerNotFoundError, OwnerNotNamespaceError, MemberNameTakenError, DeleteReferencedError, OwnerInsideTargetError, MoveReferencedError, ReferencedElsewhereError) catch one kind of refusal
TypeMismatchError / InstanceTypeError / FeatureValueError a typed view read a feature of another kind, an instance of another type, or a slot that is an error
SymbolNotFoundError the model declares no such symbol
MissingCapabilityError the service does not advertise a capability the call needs
DownloadError a release binary could not be downloaded or installed
ChecksumMismatchError a download's digest contradicts the one expected of it
UnpinnedReleaseError / UnsignedReleaseError / ManifestSignatureError nothing pins the release, nothing signs it, or a signature does not verify

The ParseError/EvaluationError split against ServiceError is the one the conformance suite draws: an expression that will not evaluate is a successful call carrying an error, not a service problem.

Capability negotiation

Clients negotiate on the capability names GetServerInfo reports, never on the version string:

import { CAPABILITY_EVALUATE_SUBJECT } from "@openmbee/opensysml";

if (connection.info.has(CAPABILITY_EVALUATE_SUBJECT)) {
  await model.eval("mass", { subject: "Demo::sedan" });
}

The client checks the advertised list before making a gated call, so MissingCapabilityError names the service, its version and how to get one that has the capability, without a round trip. A service too old to answer GetServerInfo at all is still usable: connection.info.answered is false and its capability list is empty. Capabilities that only describe how a response is populated omit the fields they name rather than refusing the call.

The service binary

The binary comes from an optional per-platform npm package (@openmbee/opensysml-sysml-grpc-{linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64}), selected by npm from its os/cpu metadata, with no postinstall script. Resolution order is $OPENSYSML_BINARY, that package, ~/.opensysml/bin/sysml-grpc (the cache the Python, Java and Rust clients share), then sysml-grpc on $PATH. resolveBinary() downloads a release into that cache when nothing above resolved, verifying it against the digests pinned in the published release-digests.json, else against the release's sigstore-signed SHA256SUMS.txt; a release neither pins nor signs is refused unless $OPENSYSML_ALLOW_UNPINNED_DOWNLOAD accepts same-origin trust explicitly. client/node/README.md documents the download and its trust model in full.

A private child is started with -port 0 -health-port 0 -report-address -exit-with-parent, and the client reads the address from its first stdout line, so no port is chosen, probed or retried. One child serves every connection of a thread — a worker_threads worker gets its own — and stops when the last connection closes. It cannot be orphaned: the client holds the write end of the child's stdin and never writes to it, so the kernel closes that pipe however this process dies, SIGKILL included, and the child exits at end of file.

In a browser

import { connect } from "@openmbee/opensysml/browser";

await using connection = await connect({ address: "https://sysml.example.com" });

Two limits to plan for: the service must allow the page's exact origin (-cors-allowed-origins https://app.example.com, never *) and be served over TLS for an HTTPS page to reach it; and connect-go does not implement the base64 grpc-web-text variant, which this fetch-based client does not need but a grpc-web client requiring -text would.

The rest of the surface

Beside the model reads above, the client covers every RPC the service offers:

  • connection.parseSources parses several documents as one model (SourceDocument.file/inline per document; model.documents, model.roots);
  • connection.convert and Node's save(target, path) write a model, file or conversion out in sysml, kerml, turtle or api-json; a SysML v1 model (xmi, uml, mdzip, by fromFormat or by extension) is refused with an InvalidRequestError naming migrate, since it is migrated, not converted;
  • connection.migrate migrates a SysML v1 model from a path or inline content bytes to sysml or ttl, with fromFormat, report, results, layoutPath/layoutContent, imageBaseUrl and strict as the command's companion flags; the Migration carries the content, a MigrationReport (summary, mapped/approximated/unmapped/skipped, entries and text when asked, byVerdict(verdict)), results and the image files, which save(migration, path) writes beside the notation; a v2 fromFormat is refused with a pointer at convert;
  • model.query (OSLC or structured), model.runDocumentQuery with ElementRef/ObjectRef bindings, model.renderDocument to Markdown or HTML;
  • model.executeAction/executeState for runs and exploreAction/exploreState/exploreAnalysis for explorations of every schedule — the two families refuse each other's schedule, as the wire does;
  • model.verifyConstraint/verifyRequirement/verifySatisfaction/satisfied, validateInstance, calc, runAnalysis, runSweep (ranges as parameter → [from, to] or [from, to, step]), all taking engine, subject, question, arguments or namedArguments as the call allows, and connection.listEngines names what the service answers with;
  • model.edit() builds an Editor/Body batch of source-preserving edits, applied atomically by apply(), with per-operation capability gating and the typed EditError family for refusals; connection.applyEdits(modelHash, ops) is the wire form, taking EditOperation messages. A model of several documents is edited in one batch — the result's documents lists every document the batch rewrote by parse name and applied[].document names where each change landed — and acceptDocuments/document options on applyEdits map the request fields directly;
  • opensysml-generate (the bin of the package) writes a module of typed views over a model's definitions, stamped with a hash of the source; a class extends its first base and re-declares the other bases' features as getters, fromInstance accepts subtypes, and --check fails on drift.

connection.rpc remains the escape hatch — the generated Connect client — and SysMLService is exported for a caller building its own.

Conformance

npm run conformance -- --allow-skips --report report.json runs the language-neutral suite through the public API, and emits the report shape tools/cmd/conformance emits. 158 scenarios per protocol over grpc, connect and connect-json: 155 pass and 3 are skipped, being the requests the public API refuses eagerly (a ParseFile naming no source, a ParseSources naming no document or two alike); a refusal the client makes in the service's own words and status (a v1 model offered to convert, a v2 one to migrate) runs as that status. --mutate <name> corrupts a response on its way through the client and each mutation must make a scenario fail, which is what keeps the run from being vacuous.