@openmbee/opensysml 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +355 -33
  2. package/dist/browser/index.d.ts +9 -0
  3. package/dist/browser/index.js +16 -0
  4. package/dist/browser/wasm-worker.d.ts +1 -0
  5. package/dist/browser/wasm-worker.js +2 -0
  6. package/dist/browser/wasm.d.ts +17 -0
  7. package/dist/browser/wasm.js +94 -0
  8. package/dist/core/arguments.d.ts +4 -0
  9. package/dist/core/arguments.js +19 -0
  10. package/dist/core/capabilities.d.ts +90 -9
  11. package/dist/core/capabilities.js +111 -10
  12. package/dist/core/connection.d.ts +203 -2
  13. package/dist/core/connection.js +810 -17
  14. package/dist/core/conversion.d.ts +153 -0
  15. package/dist/core/conversion.js +191 -0
  16. package/dist/core/document.d.ts +188 -0
  17. package/dist/core/document.js +457 -0
  18. package/dist/core/edit.d.ts +446 -0
  19. package/dist/core/edit.js +1994 -0
  20. package/dist/core/engines.d.ts +64 -0
  21. package/dist/core/engines.js +74 -0
  22. package/dist/core/errors.d.ts +164 -2
  23. package/dist/core/errors.js +209 -3
  24. package/dist/core/exploration.d.ts +71 -0
  25. package/dist/core/exploration.js +116 -0
  26. package/dist/core/graphs.d.ts +15 -0
  27. package/dist/core/graphs.js +3 -0
  28. package/dist/core/index.d.ts +30 -9
  29. package/dist/core/index.js +14 -5
  30. package/dist/core/model.d.ts +168 -8
  31. package/dist/core/model.js +258 -31
  32. package/dist/core/package.d.ts +2 -0
  33. package/dist/core/package.js +2 -0
  34. package/dist/core/query.d.ts +50 -0
  35. package/dist/core/query.js +197 -0
  36. package/dist/core/render-view.d.ts +97 -0
  37. package/dist/core/render-view.js +120 -0
  38. package/dist/core/sources.d.ts +34 -0
  39. package/dist/core/sources.js +134 -0
  40. package/dist/core/status.d.ts +9 -2
  41. package/dist/core/status.js +24 -8
  42. package/dist/core/typed.d.ts +71 -0
  43. package/dist/core/typed.js +245 -0
  44. package/dist/core/values.d.ts +127 -8
  45. package/dist/core/values.js +536 -30
  46. package/dist/core/verdict.d.ts +271 -0
  47. package/dist/core/verdict.js +567 -0
  48. package/dist/core/wasm.d.ts +114 -0
  49. package/dist/core/wasm.js +514 -0
  50. package/dist/generated/sysml_pb.d.ts +1062 -15
  51. package/dist/generated/sysml_pb.js +200 -100
  52. package/dist/node/generate-cli.d.ts +2 -0
  53. package/dist/node/generate-cli.js +10 -0
  54. package/dist/node/generate.d.ts +99 -0
  55. package/dist/node/generate.js +757 -0
  56. package/dist/node/index.d.ts +29 -2
  57. package/dist/node/index.js +211 -10
  58. package/dist/node/service.d.ts +5 -3
  59. package/dist/node/service.js +32 -26
  60. package/dist/node/wasm-source.d.ts +18 -0
  61. package/dist/node/wasm-source.js +103 -0
  62. package/dist/node/wasm-worker.d.ts +1 -0
  63. package/dist/node/wasm-worker.js +10 -0
  64. package/dist/node/wasm.d.ts +17 -0
  65. package/dist/node/wasm.js +63 -0
  66. package/package.json +19 -7
  67. package/release-digests.json +105 -0
package/README.md CHANGED
@@ -5,10 +5,18 @@ models over the `sysml-grpc` service, using the [Connect
5
5
  protocol](https://connectrpc.com/docs/protocol) with protobuf bodies. No native
6
6
  addon, so an install is a plain registry fetch.
7
7
 
8
+ For a task-oriented walkthrough, see the
9
+ [Node client guide](https://runtime.opensysml.org/clients/node/).
10
+
8
11
  ```bash
9
- npm install @openmbee/opensysml # from npm, once the first release is published
12
+ npm install @openmbee/opensysml
10
13
  ```
11
14
 
15
+ A development snapshot is published every night under the `nightly` dist-tag
16
+ (`npm install @openmbee/opensysml@nightly`), with its platform packages carrying
17
+ that night's `sysml-grpc`; `latest` stays the stable release. See [Nightly
18
+ snapshots](https://runtime.opensysml.org/project/nightly/).
19
+
12
20
  ```ts
13
21
  import { loads, connect } from "@openmbee/opensysml";
14
22
 
@@ -82,8 +90,8 @@ third a field the answer did not carry.
82
90
  `SysMLVerdict` (`holds` / `fails` / `undecided`) and `FeatureValue` (`single` /
83
91
  `many` / `error`) are unions of the same shape; every verdict arm carries a `standing` — the
84
92
  engine that answered, the strength of its evidence (`observed`, `witnessed`, `bounded`,
85
- `proved`) and the bounds it ran under — empty from a service without the `engines`
86
- capability. Integers are `bigint`, because
93
+ `proved`) and the bounds it ran under, with `reported` and `reached` beside them —
94
+ empty from a service without the `engines` capability. Integers are `bigint`, because
87
95
  the service's `int64` does not fit a `number` — an exact comparison against a
88
96
  scenario expectation would otherwise be a lie.
89
97
 
@@ -168,6 +176,91 @@ Two limits to plan for rather than discover:
168
176
  `fetch` transport, and asserts the allowed origin is answered on the preflight
169
177
  while another origin is not.
170
178
 
179
+ ## WebAssembly, without a service
180
+
181
+ `connectWasm()` uses the same `Connection`, `Model`, values and errors over the
182
+ combined `sysml-wasm` module. It serves `ParseFile`, `ParseSources`,
183
+ `GetDiagnostics`, `GetSymbol`, `Evaluate`, `Instantiate`, `ExecuteAction`,
184
+ `ExecuteState` and `GetServerInfo`. Those are the module's complete RPC surface;
185
+ other capability-gated operations fail with `MissingCapabilityError`, and a
186
+ direct unsupported RPC fails with `UNIMPLEMENTED`.
187
+ The adapter uses JSON encoding; requesting protobuf encoding is refused.
188
+
189
+ In Node, install the optional `@openmbee/opensysml-wasm` package at the same
190
+ version as this client. `connectWasm()` resolves its module and matching Go
191
+ runtime automatically and runs a worker thread by default:
192
+
193
+ ```bash
194
+ npm install @openmbee/opensysml@<version> @openmbee/opensysml-wasm@<version>
195
+ ```
196
+
197
+ ```ts
198
+ import { connectWasm } from "@openmbee/opensysml";
199
+
200
+ await using connection = await connectWasm();
201
+ const model = await connection.loads("package Demo { part def Car; }");
202
+ ```
203
+
204
+ To use a module from another source, pass both the module and its matching Go
205
+ runtime:
206
+
207
+ ```ts
208
+ await using connection = await connectWasm({
209
+ wasm: "./sysml-wasm.wasm",
210
+ wasmExec: "/path/to/the/matching/wasm_exec.js",
211
+ });
212
+ const model = await connection.loads("package Demo { part def Car; }");
213
+ ```
214
+
215
+ The WASM module and `wasm_exec.js` must come from compatible Go toolchains.
216
+ The worker remains referenced while the connection is open, so `close()` or
217
+ `await using` ends it. `thread: "inline"` runs Go on the calling thread instead;
218
+ it is useful when a worker is unavailable, but blocks that thread during a call.
219
+ Closing an inline connection disables its client surface; Go has no exit hook to
220
+ stop the running module.
221
+ A worker-mode deadline rejects the waiting call without interrupting Go, so
222
+ later worker calls queue behind work that outlived its deadline. Inline calls
223
+ run synchronously and cannot be interrupted while they block the JavaScript
224
+ thread.
225
+
226
+ In a browser, omit `worker` to run inline, or provide a module worker serving the
227
+ package's `browser/wasm-worker` entry point:
228
+
229
+ ```ts
230
+ import { connectWasm } from "@openmbee/opensysml/browser";
231
+
232
+ const worker = new Worker("/assets/opensysml-wasm-worker.js", { type: "module" });
233
+ await using connection = await connectWasm({
234
+ wasm: new URL("./sysml-wasm.wasm", import.meta.url),
235
+ wasmExec: new URL("./wasm_exec.js", import.meta.url),
236
+ worker,
237
+ });
238
+ ```
239
+
240
+ If the page loads `wasm_exec.js` itself, omit `wasmExec` to use the installed Go
241
+ constructor.
242
+
243
+ The browser worker module can be bundled from
244
+ `@openmbee/opensysml/browser/wasm-worker`. Bundle the module and runtime from
245
+ the npm package with:
246
+
247
+ ```ts
248
+ const wasm = new URL("@openmbee/opensysml-wasm/sysml-wasm.wasm", import.meta.url);
249
+ const wasmExec = new URL("@openmbee/opensysml-wasm/wasm_exec.js", import.meta.url);
250
+ ```
251
+
252
+ Or fetch both from jsDelivr, replacing `<version>` with the matching package
253
+ version:
254
+
255
+ ```text
256
+ https://cdn.jsdelivr.net/npm/@openmbee/opensysml-wasm@<version>/sysml-wasm.wasm
257
+ https://cdn.jsdelivr.net/npm/@openmbee/opensysml-wasm@<version>/wasm_exec.js
258
+ ```
259
+
260
+ Browser callers pass those URLs as `wasm` and `wasmExec`; browser package
261
+ resolution is not automatic. The combined module measures about 7.8 MB gzipped
262
+ and 5.5 MB with Brotli.
263
+
171
264
  ## Protobuf, not JSON
172
265
 
173
266
  Bodies are protobuf by default. JSON is available (`connect({ encoding: "json" })`)
@@ -228,7 +321,8 @@ Every failure is an `OpenSysMLError`. A call the service refused is a
228
321
  `"INVALID_ARGUMENT"`, …), and the statuses worth catching by themselves have a
229
322
  subclass: `ModelNotFoundError` (the service no longer holds that hash),
230
323
  `ModelFileNotFoundError`, `InvalidRequestError`, `ServiceTimeoutError`,
231
- `UnsupportedOperationError`. A name the model has not got is a
324
+ `ServiceUnavailableError` (the service was unreachable or died before
325
+ answering), `UnsupportedOperationError`. A name the model has not got is a
232
326
  `SymbolNotFoundError`, which carries the `symbolName` it looked for and the
233
327
  `suggestions` closest to it:
234
328
 
@@ -242,6 +336,22 @@ try {
242
336
  }
243
337
  ```
244
338
 
339
+ The wider surface adds its own:
340
+
341
+ | error | what happened |
342
+ | --- | --- |
343
+ | `StaleServiceError` | the running service reports another version than `version` asked for |
344
+ | `ExecutionError` | an execution the service ran failed; carries `diagnostics` |
345
+ | `WrongKindError` | a verification or analysis named a symbol of another kind |
346
+ | `AnalysisRunError` | an analysis run failed before it could report |
347
+ | `ConversionError` | the service could not write the notation asked for |
348
+ | `MigrationError` | the service could not read the SysML v1 model, so nothing of it was migrated |
349
+ | `UnsupportedValueError` | the service sent a value this version of the client cannot decode |
350
+ | `QueryError` | a `Query` failed in-band |
351
+ | `DocumentQueryError` | a `runDocumentQuery` failed in-band |
352
+ | `EditError` | an edit was refused — `failure` names which, and the subclasses (`NoEditsError`, `EditTargetError`, `InvalidEditError`, `IllegalMemberKindError`, `RenameReferencedError`, `OverlappingEditsError`, `EditResultError`, `OwnerNotFoundError`, `OwnerNotNamespaceError`, `MemberNameTakenError`, `DeleteReferencedError`, `OwnerInsideTargetError`, `MoveReferencedError`, `ReferencedElsewhereError`) catch one kind of refusal |
353
+ | `TypeMismatchError` / `InstanceTypeError` / `FeatureValueError` | a typed view read a feature of another kind, an instance of another type, or a slot that is an error |
354
+
245
355
  Source that does not parse is not a failure: `load`/`loads` return a model whose
246
356
  `hasErrors` is true and whose `diagnostics` say where. Each `ModelDiagnostic` has
247
357
  `severity`, `message`, `code` and an optional location; branch on `code`
@@ -310,8 +420,10 @@ In order, and each step refuses rather than falling back to the next:
310
420
  1. **A shipped pin.** `release-digests.json`, synced from
311
421
  `client/release-digests.json` by `python3 scripts/sync-release-digests.py`
312
422
  and published in the tarball, pins the SHA-256 of every asset of a release.
313
- Where it pins one, that is what the bytes must hash to, and a served
314
- `.sha256` that disagrees is tampering: the download fails.
423
+ The release job stamps the release it publishes into the tarball's copy
424
+ before packing it, so a published package pins its own release. Where the
425
+ table pins one, that is what the bytes must hash to, and a served `.sha256`
426
+ that disagrees is tampering: the download fails.
315
427
  2. **The release's signed manifest.** With no pin, the client downloads
316
428
  `SHA256SUMS.txt` and its sigstore bundle `SHA256SUMS.txt.bundle`, verifies
317
429
  the bundle against the release pipeline's certificate identity (the CircleCI
@@ -361,21 +473,224 @@ install @openmbee/opensysml` must not need `buf`, Go or a network fetch of a
361
473
  plugin, and a published tarball has to contain the compiled output. CI runs
362
474
  `make proto-ts` and fails on any diff, so committed and generated cannot drift.
363
475
 
364
- ## What v1 does not do
476
+ ## Several documents
477
+
478
+ `connection.parseSources` parses several documents as one model — files the
479
+ service reads, or inline content named for its diagnostics:
480
+
481
+ ```ts
482
+ import { SourceDocument } from "@openmbee/opensysml";
483
+
484
+ const model = await connection.parseSources([
485
+ SourceDocument.file("library.sysml"),
486
+ SourceDocument.inline("user.sysml", "package User { import Library::*; }"),
487
+ ]);
488
+ model.documents; // the names the parse gave them, in order
489
+ model.roots; // one root symbol per document
490
+ ```
491
+
492
+ ## Conversion and save
493
+
494
+ `connection.convert` writes a model, a file or source out in another notation —
495
+ `"sysml"`, `"kerml"`, `"turtle"`, `"api-json"` — and `save` writes the result to
496
+ a path, converting when needed:
497
+
498
+ ```ts
499
+ import { save } from "@openmbee/opensysml";
500
+
501
+ const rdf = await connection.convert("turtle", { modelHash: model.hash });
502
+ await save(model, "out.ttl"); // format comes from the extension
503
+ await save(rdf, "copy.ttl"); // a Conversion writes what it holds
504
+ ```
505
+
506
+ An experimental conversion emits a warning (`process.emitWarning`) rather than
507
+ failing, and reports it through `Conversion.experimentalNotice`.
508
+
509
+ ## Migrating a SysML v1 model
365
510
 
366
- This client covers `GetServerInfo`, `ParseFile`, `GetSymbol`, `Evaluate` and
367
- `Instantiate` — connection, lifecycle, capability negotiation, and the five RPCs
368
- above. Deliberately **not** in v1, rather than half-implemented:
511
+ A SysML v1 model — a Cameo/MagicDraw `.mdzip`, a UML XMI `.xmi` or an Eclipse
512
+ UML2 `.uml` export — is **migrated, not converted**: a conversion is lossless,
513
+ and a migration accounts for every v1 element as `mapped`, `approximated`,
514
+ `unmapped` or `skipped`. `connection.convert` refuses one with an
515
+ `InvalidRequestError` that says so and names `migrate`, whether `fromFormat` is
516
+ `xmi`, `uml` or `mdzip` or the path's extension is; `connection.migrate` is the
517
+ verb, and `save` writes what it answers with its image files beside it:
369
518
 
370
- - generated model-ergonomics types (`python -m opensysml.generate`'s equivalent);
371
- - the edit API (`ApplyEdits`);
372
- - RDF conversion (`Convert`);
373
- - verification helpers (`VerifyConstraint`, `VerifyRequirement`,
374
- `VerifySatisfaction`);
375
- - `Query`, `GetDiagnostics`, `EvaluateCalc`, `RunAnalysis`, `ExecuteAction`, `ExecuteState`.
519
+ ```ts
520
+ const migration = await connection.migrate("sysml", { path: "Model.mdzip" }, { report: true });
521
+ console.log(migration.report.summary); // migrated 93 element(s): 78 mapped, 12 approximated, …
522
+ for (const entry of migration.report.byVerdict("unmapped")) {
523
+ console.log(`${entry.kind} ${entry.name}: ${entry.note}`);
524
+ }
525
+ await save(migration, "Model.sysml"); // and images/… beside it
526
+ ```
376
527
 
377
- `connection.rpc` is the escape hatch: it is the generated Connect client, so any
378
- RPC not covered here can still be called, without the ergonomic layer.
528
+ `save` refuses, with a `RangeError` and before writing anything, a path that is
529
+ the v1 model itself and an image that would land outside the model's directory
530
+ or over the model, as `sysml -migrate -o` does.
531
+
532
+ Inline `content` is the file's bytes (`Uint8Array`) and needs `fromFormat` to
533
+ say which form they are; a v2 `fromFormat` is refused with a pointer at
534
+ `convert`. The `Migration` carries the notation (or Turtle, `"ttl"`) and a
535
+ `MigrationReport` whose `summary` and four counts always come back; `report:
536
+ true` adds every element's `MigrationEntry` and the `text` the command's
537
+ `-migration-report` writes, `results: true` the `-migration-results` index,
538
+ and `layoutPath`/`layoutContent`, `imageBaseUrl` and `strict` are the other
539
+ companion flags. Migration is experimental and warns as the RDF direction does;
540
+ a model the service cannot read at all is a `MigrationError`.
541
+
542
+ ## Query and documents
543
+
544
+ ```ts
545
+ const elements = await model.query({ oslc: "sysml:name = \"Wheel\"" });
546
+ const rows = await model.runDocumentQuery("Observatory::PartsList", {
547
+ subject: new ElementRef("Demo::sedan"),
548
+ });
549
+ const markdown = await model.renderDocument("Observatory::Overview");
550
+ ```
551
+
552
+ `query` also takes a structured form (`payload`/`scope`/`select`/`where`, or a
553
+ wire `query`), never both a structured form and `oslc`. A binding value is an
554
+ `ElementRef`, an `ObjectRef`, a primitive or a quantity; `runDocumentQuery`
555
+ answers the typed `DocumentQueryResult` — `rows` of `DocumentValue`s, the schema
556
+ the query declared — and `renderDocument` answers Markdown, or HTML with
557
+ `{ form: "html" }`.
558
+
559
+ ## Execution and exploration
560
+
561
+ ```ts
562
+ const result = await model.executeAction("Demo::Drive", {
563
+ inputs: { distance: 120 },
564
+ });
565
+ const state = await model.executeState("Demo::Ignition");
566
+ ```
567
+
568
+ `executeAction` and `executeState` answer the run — `outputs`, `events`, the
569
+ `trace` of each step — and refuse `schedule: "explore"`, because an exploration
570
+ is not a run: `exploreAction`, `exploreState` and `exploreAnalysis` answer an
571
+ `Exploration` (`outcomes`, `runs`, `budgetsHit`) instead. `{ schedule: "explore" }`
572
+ is their default; any other schedule there is refused the same way. A
573
+ `performer` names the engine a run is handed to, where the service schedules.
574
+
575
+ ## Verification, analysis and sweep
576
+
577
+ ```ts
578
+ const verdict = await model.verifyConstraint("Demo::MassBudget", {
579
+ subject: "Demo::sedan",
580
+ question: "worst-case",
581
+ });
582
+ verdict.verdict.kind; // "holds" | "fails" | "undecided"
583
+ await model.satisfied(); // every asserted satisfy holds
584
+
585
+ const validation = await model.validateInstance("Demo::sedan");
586
+ const computed = await model.calc("Demo::TotalMass", { arguments: [2] });
587
+ const analysis = await model.runAnalysis("Demo::TradeStudy", { subject: "Demo::sedan" });
588
+ const table = await model.runSweep("Demo::TradeStudy", {
589
+ power: [{ kind: "int", value: 100n }, { kind: "int", value: 300n }, { kind: "int", value: 50n }],
590
+ }, { samples: 25, seed: 7 });
591
+ ```
592
+
593
+ A verification raises `WrongKindError` for a symbol of another kind and keeps a
594
+ `fails` or `undecided` verdict as an answer rather than a failure.
595
+ `runAnalysis` answers the run's `outputs`, `verdicts`, `instances` and
596
+ `diagnostics` — a failed analysis still answers what it completed.
597
+ `runSweep` takes a `Record` of `parameter → [from, to]` or `[from, to, step]`
598
+ ranges and answers the `SweepTable` of rows, each carrying its own error when a
599
+ run fails.
600
+
601
+ ## Engines
602
+
603
+ ```ts
604
+ for (const engine of await connection.listEngines()) {
605
+ engine.name; engine.authority; // what it answers: verification, analysis, ...
606
+ }
607
+ ```
608
+
609
+ `engine` selects one on the calls above (`verifyConstraint`, `validateInstance`,
610
+ `calc`, `runAnalysis`, `verifySatisfaction`), and `explainVerdict` /
611
+ `explainStanding` say how a verdict was reached — the engine and the strength of
612
+ its evidence (`observed`, `witnessed`, `bounded`, `proved`).
613
+
614
+ ## Authoring
615
+
616
+ `model.edit()` starts a batch of source-preserving edits, applied atomically:
617
+
618
+ ```ts
619
+ const editor = model.edit();
620
+ editor.addItemDef("Car", "Demo");
621
+ editor.rename("Demo::Wheel", "Tire");
622
+ const applied = await editor.apply();
623
+ applied.documents; // every document the batch rewrote, by parse name
624
+ ```
625
+
626
+ Every `add*` of the Python client is here — `addPackage` … `addRequirement`,
627
+ `addCalcDef`, `addParameter`, `addPerformAction`, `addStateAction`,
628
+ `addAssertConstraint`, `addTransition`, `addImport`, `addDocumentation`,
629
+ `addMetadata`, `addVerify`, `addObjective`, `addSatisfy`, plus `setValue`,
630
+ `rename`, `delete(target, { cascade })` and `move(target, owner)` — and
631
+ `Body` builds guarded then/else and nested if/while/for blocks for action
632
+ bodies. Edits are validated eagerly (a `RangeError` or `TypeError` at the
633
+ `add*` call, not at `apply()`) and capability-gated per operation and per
634
+ field. `connection.applyEdits(modelHash, operations)` is the low-level form,
635
+ taking wire `EditOperation` messages.
636
+
637
+ An edit the service refuses raises the typed error for the failure —
638
+ `EditTargetError`, `RenameReferencedError`, `DeleteReferencedError`,
639
+ `MemberNameTakenError`, `OwnerNotFoundError`, `OverlappingEditsError` and the
640
+ rest of the `EditError` family, each carrying `failure`, `diagnostics`,
641
+ `referringElements` and structured `referrers`.
642
+
643
+ ## Service version and required capabilities
644
+
645
+ `connect()` negotiates what the service must be, beside what it must advertise:
646
+
647
+ ```ts
648
+ await using connection = await connect({
649
+ version: "0.9",
650
+ requireCapabilities: [CAPABILITY_VERIFY, CAPABILITY_DOCUMENT_QUERY],
651
+ });
652
+ ```
653
+
654
+ A running service that reports another version is a `StaleServiceError`
655
+ naming the required and reported versions — the remedy is to point at a service
656
+ of the required version, or accept what is running by omitting `version`
657
+ (`$OPENSYSML_GRPC_VERSION` is the environment form). A service missing a required
658
+ capability is likewise refused at connect, rather than at the first gated call.
659
+
660
+ ## Typed module generation
661
+
662
+ `opensysml-generate` writes a TypeScript module of typed views over a model's
663
+ definitions — `Vehicle`, `SportsCar` — so `model.instantiate` reads as
664
+ `vehicle.wheels`, with each feature's declared type, instead of
665
+ `tree.get("wheels")`:
666
+
667
+ ```bash
668
+ npx opensysml-generate model.sysml -o src/vehicle_types.ts
669
+ npx opensysml-generate model.sysml --check -o src/vehicle_types.ts # fails when stale
670
+ ```
671
+
672
+ ```ts
673
+ import { Vehicle } from "./vehicle_types.js";
674
+ const vehicle = Vehicle.fromInstance(await model.instantiate("Demo::Car"), tree.byId);
675
+ vehicle.wheels; // readonly Instance[], typed
676
+ vehicle.mass; // Quantity | undefined, as declared
677
+ ```
678
+
679
+ A class `extends` its first base, declares the features of the rest as getters
680
+ so every inherited feature stays reachable, and `fromInstance` accepts
681
+ subtypes; a feature of another kind raises `TypeMismatchError` or
682
+ `InstanceTypeError`. The module is stamped with a hash of the source text, and
683
+ `--check` regenerates on drift. Generated code imports only from
684
+ `@openmbee/opensysml` and runs under the browser entry point too.
685
+
686
+ ## What this client does not do
687
+
688
+ - `save` writes a single document's content; a multi-document `EditResult`
689
+ (`applied`/`documents` rather than one `content`) is written by the caller,
690
+ per `EditedDocument`.
691
+ - `connection.rpc` remains the escape hatch for anything not wrapped: it is the
692
+ generated Connect client, and `SysMLService` is exported for a caller
693
+ building its own.
379
694
 
380
695
  ## Examples
381
696
 
@@ -400,9 +715,12 @@ npm run example 03 # one, by number or name
400
715
  ## Conformance
401
716
 
402
717
  The suite in `conformance/` is the service contract, and this client runs it
403
- **through its public API** — `load`/`loads`, `eval`, `symbol`, `instantiate` —
404
- not through the generated stubs. A scenario whose RPC v1 does not cover is
405
- skipped with a reason, and the report has the same shape `tools/cmd/conformance` emits:
718
+ **through its public API** — `load`/`loads`, `parseSources`, `eval`, `symbol`,
719
+ `instantiate`, `executeAction`, `convert`, `migrate`, `applyEdits`, `verifyConstraint`,
720
+ `query`, `runDocumentQuery`, `renderDocument`, `runAnalysis`, `runSweep`,
721
+ `listEngines` — not through the generated stubs. The only scenarios skipped are
722
+ the ones whose request the public API refuses eagerly rather than asking the
723
+ service, and the report has the same shape `tools/cmd/conformance` emits:
406
724
 
407
725
  ```bash
408
726
  npm run conformance -- --allow-skips --report report.json
@@ -410,14 +728,19 @@ npm run conformance -- --allow-skips --report report.json
410
728
 
411
729
  | protocol | ran | passed | failed | skipped |
412
730
  | --- | --: | --: | --: | --: |
413
- | `grpc` | 59 | 23 | 0 | 36 |
414
- | `connect` | 59 | 23 | 0 | 36 |
415
- | `connect-json` | 59 | 23 | 0 | 36 |
416
- | **total** | **177** | **69** | **0** | **108** |
417
-
418
- The 36 skips per protocol are 35 scenarios for the 10 RPCs listed above plus one
419
- the public API cannot express: a `ParseFile` naming no source, since `load` and
420
- `loads` always name one. Every skip carries its reason in the report.
731
+ | `grpc` | 166 | 163 | 0 | 3 |
732
+ | `connect` | 166 | 163 | 0 | 3 |
733
+ | `connect-json` | 166 | 163 | 0 | 3 |
734
+ | **total** | **498** | **489** | **0** | **9** |
735
+
736
+ The 3 skips per protocol are the requests the public API cannot express because
737
+ it validates them before calling: a `ParseFile` naming no source (`load` and
738
+ `loads` always name one), a `ParseSources` naming no document and a
739
+ `ParseSources` naming two documents alike (`parseSources` refuses both). Every
740
+ skip carries its reason in the report. A refusal the client makes before asking,
741
+ in the service's own words and with the status the service would answer — a v1
742
+ model offered to `convert`, a v2 one to `migrate` — is reported as that status,
743
+ so those scenarios run rather than skip.
421
744
 
422
745
  **The runner is not vacuous.** `--mutate <name>` corrupts a response on its way
423
746
  through the client, and each mutation makes at least one scenario fail:
@@ -452,10 +775,9 @@ commands, plus the mutation checks and a stub-drift check, in both
452
775
 
453
776
  ## Release
454
777
 
455
- Nothing here is published yet; the procedure is in
456
- [docs/project/releasing.md](../../docs/project/releasing.md) under "Releasing
457
- @openmbee/opensysml to npm". In short: the core `v*` tag's `release` workflow
458
- publishes this package and its five per-platform packages at the version
778
+ The package and its five per-platform `sysml-grpc` packages are published to npm with each core
779
+ `v*` release. The procedure is in [docs/project/releasing.md](../../docs/project/releasing.md)
780
+ under "Releasing @openmbee/opensysml to npm". The `release` workflow publishes at the version
459
781
  `client/python/opensysml/_version.py` declares, carrying the release's own
460
782
  `sysml-grpc` binaries. The granular npm token it needs already lives in the
461
783
  `npm` context; granular tokens expire after at most 90 days, so rotation before
@@ -1,9 +1,18 @@
1
1
  import { Connection } from "../core/connection.js";
2
2
  import type { TransportOptions } from "../core/connection.js";
3
3
  export * from "../core/index.js";
4
+ export { connectWasm } from "./wasm.js";
5
+ export type { BrowserWasmConnectOptions } from "./wasm.js";
4
6
  /** How a browser connects: the address is required, because nothing can be started. */
5
7
  export interface BrowserConnectOptions extends TransportOptions {
6
8
  address: string;
9
+ /**
10
+ * Release tag the service must report. 'latest' cannot be resolved in a
11
+ * browser, so it requires nothing.
12
+ */
13
+ version?: string;
14
+ /** Capabilities the service must report for the connection to be returned. */
15
+ requireCapabilities?: readonly string[];
7
16
  }
8
17
  /**
9
18
  * Connects to a running service. The service must allow this page's exact origin
@@ -5,6 +5,7 @@ import { Connection } from "../core/connection.js";
5
5
  import { OpenSysMLError } from "../core/errors.js";
6
6
  import { baseUrl, encodingOf, interceptors, timeoutOf } from "../core/transport.js";
7
7
  export * from "../core/index.js";
8
+ export { connectWasm } from "./wasm.js";
8
9
  /**
9
10
  * Connects to a running service. The service must allow this page's exact origin
10
11
  * (`-cors-allowed-origins`) and, from an HTTPS page, must be served over TLS.
@@ -21,6 +22,9 @@ export async function connect(options) {
21
22
  useBinaryFormat: encoding === "protobuf",
22
23
  interceptors: interceptors(options),
23
24
  });
25
+ const required = options.version === undefined || options.version === "" || options.version === "latest"
26
+ ? undefined
27
+ : options.version;
24
28
  return Connection.open({
25
29
  transport,
26
30
  encoding,
@@ -28,7 +32,19 @@ export async function connect(options) {
28
32
  origin: url,
29
33
  // Nothing to release: the page never owned the service.
30
34
  release: () => Promise.resolve(),
35
+ warn: (message) => {
36
+ console.warn(message);
37
+ },
31
38
  },
32
39
  timeoutMs,
40
+ ...(required === undefined ? {} : { requiredVersion: required }),
41
+ ...(options.requireCapabilities === undefined
42
+ ? {}
43
+ : { requiredCapabilities: options.requireCapabilities }),
44
+ stale: {
45
+ address: url,
46
+ remedy: `point this page at a sysml-grpc that reports ${required ?? "a matching release"}, ` +
47
+ `or accept what is running by omitting version`,
48
+ },
33
49
  });
34
50
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { serveWasmPort } from "../core/wasm.js";
2
+ serveWasmPort(globalThis);
@@ -0,0 +1,17 @@
1
+ import { Connection } from "../core/connection.js";
2
+ import type { TransportOptions } from "../core/connection.js";
3
+ /** Options for connecting to a Go WebAssembly module in a browser. */
4
+ export interface BrowserWasmConnectOptions extends TransportOptions {
5
+ /** Module URL, response, bytes, or a compiled WebAssembly module. */
6
+ wasm: string | URL | Response | ArrayBuffer | Uint8Array | WebAssembly.Module;
7
+ /** wasm_exec.js from the Go toolchain that built the module. */
8
+ wasmExec?: string | URL;
9
+ /** A worker running the package's browser WASM worker entry point. */
10
+ worker?: Worker;
11
+ version?: string;
12
+ requireCapabilities?: readonly string[];
13
+ }
14
+ /**
15
+ * Connects to sysml-wasm inline, or in the provided worker.
16
+ */
17
+ export declare function connectWasm(options: BrowserWasmConnectOptions): Promise<Connection>;
@@ -0,0 +1,94 @@
1
+ import { Connection } from "../core/connection.js";
2
+ import { connectWasmHost, instantiateInline, loadGoConstructor, loadWasmSource, WorkerWasmHost, } from "../core/wasm.js";
3
+ /**
4
+ * Connects to sysml-wasm inline, or in the provided worker.
5
+ */
6
+ export async function connectWasm(options) {
7
+ const host = options.worker === undefined
8
+ ? await startInline(options.wasm, options.wasmExec)
9
+ : await startWorker(options.worker, options.wasm, options.wasmExec);
10
+ return connectWasmHost(host, options);
11
+ }
12
+ async function startInline(wasm, wasmExec) {
13
+ const module = await browserWasmSource(wasm);
14
+ const Go = await loadGoConstructor(wasmExec === undefined ? undefined : resourceUrl(wasmExec));
15
+ return instantiateInline(module, Go);
16
+ }
17
+ async function startWorker(worker, wasm, wasmExec) {
18
+ const host = new WorkerWasmHost(browserWorkerEndpoint(worker));
19
+ try {
20
+ const { source, transfer } = await workerSource(wasm);
21
+ return await host.start({
22
+ type: "init",
23
+ wasm: source,
24
+ ...(wasmExec === undefined ? {} : { wasmExec: resourceUrl(wasmExec) }),
25
+ }, transfer);
26
+ }
27
+ catch (error) {
28
+ await host.close();
29
+ throw error;
30
+ }
31
+ }
32
+ function browserWorkerEndpoint(worker) {
33
+ return {
34
+ post(message, transfer) {
35
+ worker.postMessage(message, [...transfer]);
36
+ },
37
+ onMessage(listener) {
38
+ worker.addEventListener("message", (event) => {
39
+ listener(event.data);
40
+ });
41
+ },
42
+ onFailure(listener) {
43
+ worker.addEventListener("error", (event) => {
44
+ listener(new Error(event.message || "the sysml-wasm worker failed"));
45
+ });
46
+ worker.addEventListener("messageerror", () => {
47
+ listener(new Error("the sysml-wasm worker sent an unreadable message"));
48
+ });
49
+ },
50
+ terminate() {
51
+ worker.terminate();
52
+ },
53
+ };
54
+ }
55
+ async function workerSource(wasm) {
56
+ if (wasm instanceof WebAssembly.Module) {
57
+ return { source: wasm, transfer: [] };
58
+ }
59
+ if (wasm instanceof Response) {
60
+ if (wasm.bodyUsed) {
61
+ throw new Error("the WebAssembly Response body has already been read");
62
+ }
63
+ const bytes = await wasm.arrayBuffer();
64
+ return { source: bytes, transfer: [bytes] };
65
+ }
66
+ if (wasm instanceof ArrayBuffer) {
67
+ const bytes = wasm.slice(0);
68
+ return { source: bytes, transfer: [bytes] };
69
+ }
70
+ if (wasm instanceof Uint8Array) {
71
+ const bytes = Uint8Array.from(wasm).buffer;
72
+ return { source: bytes, transfer: [bytes] };
73
+ }
74
+ const url = wasm instanceof URL ? wasm.href : resourceUrl(wasm);
75
+ return { source: url, transfer: [] };
76
+ }
77
+ async function browserWasmSource(wasm) {
78
+ if (wasm instanceof WebAssembly.Module ||
79
+ wasm instanceof ArrayBuffer ||
80
+ wasm instanceof Uint8Array) {
81
+ return wasm;
82
+ }
83
+ if (wasm instanceof Response && wasm.bodyUsed) {
84
+ throw new Error("the WebAssembly Response body has already been read");
85
+ }
86
+ return loadWasmSource(wasm instanceof Response ? wasm : wasm instanceof URL ? wasm : resourceUrl(wasm));
87
+ }
88
+ function resourceUrl(value) {
89
+ if (value instanceof URL) {
90
+ return value.href;
91
+ }
92
+ const base = typeof document === "undefined" ? import.meta.url : document.baseURI;
93
+ return new URL(value, base).href;
94
+ }
@@ -0,0 +1,4 @@
1
+ /** Refuses `value` that is not a string, naming the parameter it was passed as. */
2
+ export declare function requireString(param: string, value: unknown): asserts value is string;
3
+ /** Refuses `value` that is not a string, or one whose lone surrogates cannot be sent. */
4
+ export declare function requireSourceText(param: string, value: unknown): asserts value is string;
@@ -0,0 +1,19 @@
1
+ // Guards the public entry points reach before any call: a non-string argument
2
+ // is refused as a TypeError, and source text a lone UTF-16 surrogate makes
3
+ // unsendable is refused as a RangeError, as Python's client refuses them.
4
+ // One half of a surrogate pair without the other (a high surrogate followed by
5
+ // anything but a low surrogate, or a low surrogate not led by a high one).
6
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
7
+ /** Refuses `value` that is not a string, naming the parameter it was passed as. */
8
+ export function requireString(param, value) {
9
+ if (typeof value !== "string") {
10
+ throw new TypeError(`${param} must be a string, got ${value === null ? "null" : typeof value}`);
11
+ }
12
+ }
13
+ /** Refuses `value` that is not a string, or one whose lone surrogates cannot be sent. */
14
+ export function requireSourceText(param, value) {
15
+ requireString(param, value);
16
+ if (LONE_SURROGATE.test(value)) {
17
+ throw new RangeError(`${param} contains a lone UTF-16 surrogate, which cannot be sent to the service`);
18
+ }
19
+ }