@cfasim-ui/shared 0.9.3 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/shared",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Shared utilities for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -17,7 +17,8 @@
17
17
  ],
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
20
- "./transfer": "./src/transferUtils.ts"
20
+ "./transfer": "./src/transferUtils.ts",
21
+ "./workerError": "./src/workerError.ts"
21
22
  },
22
23
  "peerDependencies": {
23
24
  "vue": "^3.5.0"
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { ModelOutput } from "./ModelOutput.js";
2
+ import { ModelOutput, DATE_MISSING, isoDate } from "./ModelOutput.js";
3
3
  import type {
4
4
  ModelOutputWire,
5
5
  ModelOutputsWire,
@@ -207,3 +207,20 @@ describe("transfer protocol with ModelOutputs", () => {
207
207
  expect(unwrapResponse(response)).toBe("hello");
208
208
  });
209
209
  });
210
+
211
+ describe("date columns", () => {
212
+ it("decodes days since the epoch to ISO strings and CSV", () => {
213
+ const output = new ModelOutput(
214
+ 3,
215
+ [{ name: "when", type: "date" }],
216
+ [new Uint32Array([0, 20458, DATE_MISSING])],
217
+ );
218
+ expect(output.dates("when")).toEqual(["1970-01-01", "2026-01-05", null]);
219
+ expect(isoDate(20465)).toBe("2026-01-12");
220
+ expect(isoDate(0)).toBe("1970-01-01");
221
+ expect(isoDate(2_932_897)).toBe("10000-01-01");
222
+ expect(isoDate(4_000_000_000)).toBeNull();
223
+ expect(modelOutputToCSV(output)).toBe("when\n1970-01-01\n2026-01-05\n");
224
+ expect(() => output.dates("nope")).toThrow();
225
+ });
226
+ });
@@ -1,4 +1,8 @@
1
- export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum";
1
+ export type ColumnType = "i32" | "u32" | "f64" | "bool" | "enum" | "date";
2
+
3
+ /** Value of a `date` column with no date (`DATE_MISSING` on the Python side). */
4
+ export const DATE_MISSING = 0xffffffff;
5
+ const DAY_MS = 86_400_000;
2
6
 
3
7
  export interface ColumnDescriptor {
4
8
  name: string;
@@ -20,17 +24,27 @@ export interface ModelOutputsWire {
20
24
  outputs: Record<string, ModelOutputWire>;
21
25
  }
22
26
 
23
- const COLUMN_TYPE_TO_CTOR: Record<
27
+ export const COLUMN_TYPE_TO_CTOR: Record<
24
28
  ColumnType,
25
- new (buffer: ArrayBuffer) => TypedColumn
29
+ { new (buffer: ArrayBuffer): TypedColumn; BYTES_PER_ELEMENT: number }
26
30
  > = {
27
31
  f64: Float64Array,
28
32
  i32: Int32Array,
29
33
  u32: Uint32Array,
30
34
  bool: Uint8Array,
31
35
  enum: Uint32Array,
36
+ date: Uint32Array,
32
37
  };
33
38
 
39
+ /** ISO `yyyy-mm-dd` for a `date` column value (days since 1970-01-01). */
40
+ export function isoDate(days: number): string | null {
41
+ const date = new Date(days * DAY_MS);
42
+ if (days === DATE_MISSING || Number.isNaN(date.getTime())) return null;
43
+ // Not toISOString, which writes years past 9999 as "+010000".
44
+ const pad = (n: number, width = 2) => String(n).padStart(width, "0");
45
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
46
+ }
47
+
34
48
  export class ModelOutput {
35
49
  readonly length: number;
36
50
  readonly columns: readonly ColumnDescriptor[];
@@ -66,6 +80,14 @@ export class ModelOutput {
66
80
  return desc.enumLabels[index];
67
81
  }
68
82
 
83
+ /** A `date` column as ISO strings (`null` where the date is missing). */
84
+ dates(columnName: string): (string | null)[] {
85
+ if (this.descriptor(columnName).type !== "date") {
86
+ throw new Error(`Column ${columnName} is not a date`);
87
+ }
88
+ return Array.from(this.column(columnName), isoDate);
89
+ }
90
+
69
91
  get names(): string[] {
70
92
  return this.columns.map((c) => c.name);
71
93
  }
package/src/csv.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ModelOutput } from "./ModelOutput.js";
2
+ import { isoDate } from "./ModelOutput.js";
2
3
 
3
4
  export function modelOutputToCSV(output: ModelOutput): string {
4
5
  const header = output.names.join(",");
@@ -10,6 +11,8 @@ export function modelOutputToCSV(output: ModelOutput): string {
10
11
  const val = output.buffers[c][r];
11
12
  if (desc.type === "enum") {
12
13
  cells.push(desc.enumLabels![val as number]);
14
+ } else if (desc.type === "date") {
15
+ cells.push(isoDate(val as number) ?? "");
13
16
  } else if (desc.type === "bool") {
14
17
  cells.push(val ? "true" : "false");
15
18
  } else {
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { decodeModelOutputs } from "./decodeModelOutputs.js";
5
+
6
+ // Shared protocol fixtures, written by the Python reference implementation.
7
+ // (__dirname rather than import.meta.url: happy-dom gives the latter an http scheme.)
8
+ const FIXTURES = resolve(__dirname, "../../../cfasim-model-py/tests/fixtures");
9
+
10
+ function fixtureBuffer(name: string): ArrayBuffer {
11
+ const bytes = readFileSync(resolve(FIXTURES, name));
12
+ return bytes.buffer.slice(
13
+ bytes.byteOffset,
14
+ bytes.byteOffset + bytes.byteLength,
15
+ ) as ArrayBuffer;
16
+ }
17
+
18
+ const expected = JSON.parse(
19
+ readFileSync(resolve(FIXTURES, "sample.columns.json"), "utf8"),
20
+ ) as Record<string, Record<string, number[]>>;
21
+
22
+ describe("decodeModelOutputs", () => {
23
+ it("decodes the shared fixture into ModelOutput tables", () => {
24
+ const outputs = decodeModelOutputs(fixtureBuffer("sample.cfasim"));
25
+ expect(Object.keys(outputs).sort()).toEqual(["empty", "series"]);
26
+ for (const [table, columns] of Object.entries(expected)) {
27
+ for (const [name, values] of Object.entries(columns)) {
28
+ expect(Array.from(outputs[table].column(name))).toEqual(values);
29
+ }
30
+ }
31
+ expect(outputs.series.column("time")).toBeInstanceOf(Float64Array);
32
+ expect(outputs.series.column("delta")).toBeInstanceOf(Int32Array);
33
+ expect(outputs.series.column("count")).toBeInstanceOf(Uint32Array);
34
+ expect(outputs.series.column("flag")).toBeInstanceOf(Uint8Array);
35
+ const status = outputs.series.column("status");
36
+ expect(outputs.series.label("status", status[1])).toBe("R");
37
+ expect(outputs.empty.length).toBe(0);
38
+ });
39
+
40
+ it("matches the committed header", () => {
41
+ const buf = fixtureBuffer("sample.cfasim");
42
+ const headerLength = new DataView(buf).getUint32(0, true);
43
+ const header = JSON.parse(
44
+ new TextDecoder().decode(new Uint8Array(buf, 4, headerLength)),
45
+ );
46
+ const dataStart = 4 + headerLength;
47
+ const committed = JSON.parse(
48
+ readFileSync(resolve(FIXTURES, "sample.header.json"), "utf8"),
49
+ );
50
+ expect(header).toEqual(committed);
51
+ expect(dataStart % 8).toBe(0);
52
+ });
53
+
54
+ it("rejects truncated and malformed files", () => {
55
+ const buf = fixtureBuffer("sample.cfasim");
56
+ expect(() => decodeModelOutputs(buf.slice(0, buf.byteLength - 4))).toThrow(
57
+ /truncated/,
58
+ );
59
+ expect(() => decodeModelOutputs(buf.slice(0, 2))).toThrow(/truncated/);
60
+ const bad = new Uint8Array(buf.slice(0));
61
+ const text = new TextDecoder().decode(bad.subarray(4, 24));
62
+ const idx = 4 + text.indexOf('"version":1') + '"version":'.length;
63
+ bad[idx] = "2".charCodeAt(0);
64
+ expect(() => decodeModelOutputs(bad.buffer as ArrayBuffer)).toThrow(
65
+ /version/,
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,80 @@
1
+ import type { ColumnDescriptor } from "./ModelOutput.js";
2
+ import { COLUMN_TYPE_TO_CTOR, ModelOutput } from "./ModelOutput.js";
3
+
4
+ /**
5
+ * Header of a `.cfasim` outputs file. The file is a little-endian u32 header
6
+ * length, a UTF-8 JSON header (space-padded to an 8-byte boundary), then the
7
+ * raw column buffers; `byteOffsets` are relative to the start of the buffers.
8
+ */
9
+ export interface ModelOutputsFileTable {
10
+ length: number;
11
+ columns: ColumnDescriptor[];
12
+ byteOffsets: number[];
13
+ /** Unpadded byte length per column. */
14
+ byteLengths: number[];
15
+ }
16
+
17
+ export interface ModelOutputsFileHeader {
18
+ version: number;
19
+ outputs: Record<string, ModelOutputsFileTable>;
20
+ }
21
+
22
+ function readModelOutputsHeader(buffer: ArrayBuffer): {
23
+ header: ModelOutputsFileHeader;
24
+ dataStart: number;
25
+ } {
26
+ if (buffer.byteLength < 4) {
27
+ throw new Error("outputs file is truncated (no header length)");
28
+ }
29
+ const headerLength = new DataView(buffer).getUint32(0, true);
30
+ const dataStart = 4 + headerLength;
31
+ if (dataStart > buffer.byteLength) {
32
+ throw new Error("outputs file is truncated (header)");
33
+ }
34
+ const text = new TextDecoder().decode(
35
+ new Uint8Array(buffer, 4, headerLength),
36
+ );
37
+ const header = JSON.parse(text) as ModelOutputsFileHeader;
38
+ if (header.version !== 1) {
39
+ throw new Error(`unsupported outputs file version ${header.version}`);
40
+ }
41
+ return { header, dataStart };
42
+ }
43
+
44
+ /**
45
+ * Decode a `.cfasim` outputs file (as served by `GET /api/runs/{id}/outputs`
46
+ * or read from disk) into named `ModelOutput` tables. Column buffers are
47
+ * copied out of `buffer`, so the file bytes can be released afterwards.
48
+ */
49
+ export function decodeModelOutputs(
50
+ buffer: ArrayBuffer,
51
+ ): Record<string, ModelOutput> {
52
+ const { header, dataStart } = readModelOutputsHeader(buffer);
53
+ const result: Record<string, ModelOutput> = {};
54
+ for (const [name, table] of Object.entries(header.outputs)) {
55
+ const buffers = table.columns.map((col, i) => {
56
+ const bpe = COLUMN_TYPE_TO_CTOR[col.type]?.BYTES_PER_ELEMENT;
57
+ if (bpe === undefined) {
58
+ throw new Error(`column ${col.name} has unknown type ${col.type}`);
59
+ }
60
+ const start = dataStart + table.byteOffsets[i];
61
+ const bytes = table.byteLengths[i];
62
+ if (bytes !== table.length * bpe) {
63
+ throw new Error(
64
+ `column ${col.name} has ${bytes} bytes for ${table.length} rows`,
65
+ );
66
+ }
67
+ if (start + bytes > buffer.byteLength) {
68
+ throw new Error(`outputs file is truncated (column ${col.name})`);
69
+ }
70
+ return buffer.slice(start, start + bytes);
71
+ });
72
+ result[name] = ModelOutput.fromWire({
73
+ __modelOutput: true,
74
+ length: table.length,
75
+ columns: table.columns,
76
+ buffers,
77
+ });
78
+ }
79
+ return result;
80
+ }
package/src/index.ts CHANGED
@@ -4,8 +4,8 @@ export {
4
4
  postErrorWithTransfer,
5
5
  unwrapResponse,
6
6
  } from "./transferUtils.js";
7
- export type { TransferableResponse } from "./transferUtils.js";
8
- export { ModelOutput } from "./ModelOutput.js";
7
+ export type { TransferableResponse, WasmTrapKind } from "./transferUtils.js";
8
+ export { ModelOutput, isoDate, DATE_MISSING } from "./ModelOutput.js";
9
9
  export type {
10
10
  ColumnDescriptor,
11
11
  ColumnType,
@@ -14,7 +14,18 @@ export type {
14
14
  ModelOutputsWire,
15
15
  } from "./ModelOutput.js";
16
16
  export { modelOutputToCSV } from "./csv.js";
17
- export { describeWorkerError, MESSAGEERROR_TEXT } from "./workerError.js";
17
+ export { decodeModelOutputs } from "./decodeModelOutputs.js";
18
+ export type {
19
+ ModelOutputsFileHeader,
20
+ ModelOutputsFileTable,
21
+ } from "./decodeModelOutputs.js";
22
+ export {
23
+ describeWorkerError,
24
+ describeWasmTrap,
25
+ describeWasmInitTrap,
26
+ isWasmTrap,
27
+ MESSAGEERROR_TEXT,
28
+ } from "./workerError.js";
18
29
  export { formatNumber, isNumberFormat } from "./formatNumber.js";
19
30
  export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";
20
31
  export {
@@ -8,6 +8,8 @@ interface ModelOutputsResponsePayload {
8
8
  >;
9
9
  }
10
10
 
11
+ export type WasmTrapKind = "call" | "init";
12
+
11
13
  export interface TransferableResponse {
12
14
  id: number;
13
15
  result?: unknown;
@@ -15,6 +17,13 @@ export interface TransferableResponse {
15
17
  bufferType?: string;
16
18
  modelOutputs?: ModelOutputsResponsePayload;
17
19
  error?: string;
20
+ /**
21
+ * The call died in a WebAssembly trap and the instance is no longer
22
+ * trustworthy: `"call"` inside the function, `"init"` while the module was
23
+ * initializing (its glue keeps the half-initialized instance, so only a new
24
+ * worker can retry).
25
+ */
26
+ trap?: WasmTrapKind;
18
27
  }
19
28
 
20
29
  const TYPED_ARRAY_CONSTRUCTORS: Record<
@@ -94,10 +103,12 @@ export function postErrorWithTransfer(
94
103
  port: typeof globalThis,
95
104
  id: number,
96
105
  error: unknown,
106
+ options: { trap?: WasmTrapKind } = {},
97
107
  ): void {
98
108
  port.postMessage({
99
109
  id,
100
110
  error: error instanceof Error ? error.message : String(error),
111
+ ...(options.trap ? { trap: options.trap } : {}),
101
112
  } satisfies TransferableResponse);
102
113
  }
103
114
 
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ describeWasmTrap,
4
+ describeWasmInitTrap,
5
+ describeWorkerError,
6
+ isWasmTrap,
7
+ } from "./workerError.js";
8
+ import { postErrorWithTransfer } from "./transferUtils.js";
9
+
10
+ describe("describeWasmTrap", () => {
11
+ it("reports a recorded panic with its location", () => {
12
+ expect(
13
+ describeWasmTrap(
14
+ "unreachable",
15
+ "panicked at src/lib.rs:21:9: rate must be <= 5, got 9",
16
+ true,
17
+ ),
18
+ ).toBe("Model panicked at src/lib.rs:21:9: rate must be <= 5, got 9");
19
+ });
20
+
21
+ it("explains an unreachable trap with no panic as OOM or a replaced hook", () => {
22
+ expect(describeWasmTrap("unreachable", undefined, true)).toMatch(
23
+ /aborted without a recorded panic message.*out of memory.*replaced the panic hook/,
24
+ );
25
+ });
26
+
27
+ it("recognizes every engine's wording of the unreachable trap", () => {
28
+ for (const text of [
29
+ "unreachable executed",
30
+ "Unreachable code should not be executed (evaluating 'wasm.simulate(a)')",
31
+ ]) {
32
+ expect(describeWasmTrap(text, undefined, true)).toMatch(/aborted/);
33
+ expect(describeWasmTrap(text, undefined, false)).toMatch(/rebuild/);
34
+ }
35
+ });
36
+
37
+ it("asks for a rebuild against current cfasim-model when the hook is missing", () => {
38
+ expect(describeWasmTrap("unreachable", undefined, false)).toMatch(
39
+ /rebuild it against the current cfasim-model/,
40
+ );
41
+ });
42
+
43
+ it("passes other trap messages through", () => {
44
+ expect(
45
+ describeWasmTrap("memory access out of bounds", undefined, true),
46
+ ).toBe("Model trapped: memory access out of bounds");
47
+ });
48
+ });
49
+
50
+ describe("describeWasmInitTrap", () => {
51
+ it("explains an abort in init and passes other traps through", () => {
52
+ expect(describeWasmInitTrap("unreachable executed")).toMatch(
53
+ /panicked or aborted while initializing/,
54
+ );
55
+ expect(describeWasmInitTrap("Maximum call stack size exceeded")).toBe(
56
+ "Model trapped while initializing: Maximum call stack size exceeded",
57
+ );
58
+ });
59
+ });
60
+
61
+ describe("isWasmTrap", () => {
62
+ it("recognizes traps and engine stack overflows, not ordinary errors", () => {
63
+ expect(isWasmTrap(new WebAssembly.RuntimeError("unreachable"))).toBe(true);
64
+ expect(isWasmTrap(new RangeError("Maximum call stack size exceeded"))).toBe(
65
+ true,
66
+ );
67
+ const spiderMonkey = new Error("too much recursion");
68
+ spiderMonkey.name = "InternalError";
69
+ expect(isWasmTrap(spiderMonkey)).toBe(true);
70
+ expect(isWasmTrap(new RangeError("Invalid array length"))).toBe(false);
71
+ expect(isWasmTrap(new Error("rate must be <= 5"))).toBe(false);
72
+ expect(isWasmTrap("unreachable")).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("postErrorWithTransfer", () => {
77
+ it("flags a trap only when asked", () => {
78
+ const port = { postMessage: vi.fn() } as unknown as typeof globalThis;
79
+ postErrorWithTransfer(port, 1, new Error("boom"));
80
+ postErrorWithTransfer(port, 2, "plain", { trap: "call" });
81
+ expect(port.postMessage).toHaveBeenNthCalledWith(1, {
82
+ id: 1,
83
+ error: "boom",
84
+ });
85
+ expect(port.postMessage).toHaveBeenNthCalledWith(2, {
86
+ id: 2,
87
+ error: "plain",
88
+ trap: "call",
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("describeWorkerError", () => {
94
+ it("falls back to a hint when the event is sanitized", () => {
95
+ const e = { message: "", filename: "", lineno: 0 } as ErrorEvent;
96
+ expect(describeWorkerError(e)).toMatch(/no error details/);
97
+ });
98
+ });
@@ -15,3 +15,55 @@ export function describeWorkerError(e: ErrorEvent): string {
15
15
 
16
16
  export const MESSAGEERROR_TEXT =
17
17
  "Worker received a message it could not deserialize (messageerror)";
18
+
19
+ /**
20
+ * Message for a call that died in a WebAssembly trap. `panic` is the text
21
+ * recorded by `cfasim-model`'s panic hook (`panicked at <file>:<line>:<col>:
22
+ * <message>`) when the model panicked. `canRecord` says the module exports
23
+ * that hook at all; when it does not, the model is built on a cfasim-model
24
+ * too old to say. An `unreachable` trap with nothing recorded is an abort:
25
+ * the allocator giving up, or a panic after the model replaced the hook.
26
+ * Engines word the trap differently ("unreachable", "unreachable executed",
27
+ * "Unreachable code should not be executed"), hence the loose match.
28
+ */
29
+ export function describeWasmTrap(
30
+ trapMessage: string,
31
+ panic: string | undefined,
32
+ canRecord: boolean,
33
+ ): string {
34
+ if (panic) return `Model ${panic}`;
35
+ if (!/unreachable/i.test(trapMessage)) return `Model trapped: ${trapMessage}`;
36
+ if (canRecord) {
37
+ return "Model aborted without a recorded panic message: it ran out of memory, or it replaced the panic hook outside a start function";
38
+ }
39
+ return "Model panicked or aborted (unreachable); rebuild it against the current cfasim-model to see the panic message";
40
+ }
41
+
42
+ /**
43
+ * Message for a trap while the module initialized. The panic hook is
44
+ * installed after init, so a start-function panic has no recorded message.
45
+ */
46
+ export function describeWasmInitTrap(trapMessage: string): string {
47
+ if (/unreachable/i.test(trapMessage)) {
48
+ return "Model panicked or aborted while initializing, before the panic hook could be installed";
49
+ }
50
+ return `Model trapped while initializing: ${trapMessage}`;
51
+ }
52
+
53
+ /**
54
+ * Whether an error thrown by a wasm call left the instance unusable. A
55
+ * `WebAssembly.RuntimeError` is any trap (a Rust panic or abort ends in
56
+ * `unreachable`). A stack overflow is not a trap but unwinds through the
57
+ * wasm frames without running their epilogues, so the shadow stack pointer
58
+ * is left wherever it was: V8 throws a `RangeError`, SpiderMonkey an
59
+ * `InternalError`. A JS-side overflow in the worker would match too; the
60
+ * worker has no recursion of its own, and the cost is one spare replacement.
61
+ */
62
+ export function isWasmTrap(error: unknown): boolean {
63
+ if (error instanceof WebAssembly.RuntimeError) return true;
64
+ if (!(error instanceof Error)) return false;
65
+ return (
66
+ (error.name === "RangeError" || error.name === "InternalError") &&
67
+ /call stack|too much recursion/i.test(error.message)
68
+ );
69
+ }