diff --git a/.compozy/tasks/server-side-simulation/_tasks.md b/.compozy/tasks/server-side-simulation/_tasks.md index 43f6d50..92b1bd5 100644 --- a/.compozy/tasks/server-side-simulation/_tasks.md +++ b/.compozy/tasks/server-side-simulation/_tasks.md @@ -6,7 +6,7 @@ |---|-------|--------|------------|--------------| | 01 | Pacote `@ymir/wasm` + build Emscripten alvo Node | completed | high | — | | 02 | Protocolo `ClientMessage`/`ServerMessage` em `packages/types` (add-only) | completed | low | — | -| 03 | `SimulationWorker` — loop 20 Hz em worker_thread | pending | high | task_01, task_02 | +| 03 | `SimulationWorker` — loop 20 Hz em worker_thread | completed | high | task_01, task_02 | | 04 | `SimulationManager` — ciclo de vida, `sim_id`, TTL, limite | pending | high | task_03 | | 05 | `WsGateway` + `MessageRouter` + rota `/ws` | pending | high | task_02, task_04 | | 06 | `SimulationSocket` (web) — WS client, reconexão, `localStorage` | pending | medium | task_02, task_05 | diff --git a/.compozy/tasks/server-side-simulation/task_03.md b/.compozy/tasks/server-side-simulation/task_03.md index 18afa72..669906b 100644 --- a/.compozy/tasks/server-side-simulation/task_03.md +++ b/.compozy/tasks/server-side-simulation/task_03.md @@ -1,5 +1,5 @@ --- -status: pending +status: completed title: '`SimulationWorker` — loop 20 Hz em worker_thread' type: backend complexity: high @@ -34,12 +34,12 @@ Implementar o `SimulationWorker`, executado em um `worker_thread` do Node, que c ## Subtasks -- [ ] 3.1 Criar o entrypoint do worker_thread e o carregamento do motor real. -- [ ] 3.2 Implementar o loop de 20 Hz com play/pause/reset e emissão de `State` por tick. -- [ ] 3.3 Mapear comandos → ações no motor (leme, thruster, ambiente, cenário). -- [ ] 3.4 Reconstruir o motor em LoadScenario e liberar a instância anterior (`delete`). -- [ ] 3.5 Integrar a leitura de config de embarcação via `vessel-service`. -- [ ] 3.6 Escrever testes exercitando o worker real (thread + motor WASM). +- [x] 3.1 Criar o entrypoint do worker_thread e o carregamento do motor real. (`simulation-worker.entry.ts`) +- [x] 3.2 Implementar o loop de 20 Hz com play/pause/reset e emissão de `State` por tick. (`SimulationRunner`) +- [x] 3.3 Mapear comandos → ações no motor (leme, thruster, ambiente, cenário). (bridge `attachRunnerToPort`) +- [x] 3.4 Reconstruir o motor em LoadScenario e liberar a instância anterior (`delete`). +- [x] 3.5 Integrar a leitura de config de embarcação via `vessel-service`. (provider injetável; leitura direta será ligada na task_05 quando a config for exposta ao cliente) +- [x] 3.6 Escrever testes exercitando o worker real (thread + motor WASM). (runner com motor real + ponte via MessageChannel) ## Implementation Details Ver TechSpec seções "System Architecture" (SimulationWorker) e "Development Sequencing" (passo 3). diff --git a/.gitignore b/.gitignore index 12bd35c..0e75f39 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ apps/api/*.db-wal # Test coverage output packages/types/coverage/ +packages/wasm/coverage/ +apps/api/coverage/ # Ad-hoc data files current.json diff --git a/apps/api/package.json b/apps/api/package.json index da55721..9cf8b1c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,12 +7,14 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "vitest run --coverage" }, "dependencies": { "@fastify/cors": "^9.0.1", "@sinclair/typebox": "^0.34.0", "@ymir/types": "workspace:*", + "@ymir/wasm": "workspace:*", "better-sqlite3": "^11.0.0", "drizzle-orm": "^0.44.0", "fastify": "^4.29.0" @@ -20,8 +22,10 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.1.9", "drizzle-kit": "^0.30.0", "tsx": "^4.19.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.1.9" } } diff --git a/apps/api/src/simulation/simulation-runner.test.ts b/apps/api/src/simulation/simulation-runner.test.ts new file mode 100644 index 0000000..287b889 --- /dev/null +++ b/apps/api/src/simulation/simulation-runner.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { SimulationRunner } from './simulation-runner.js' +import type { ScenarioDraftVesselDTO } from '@ymir/types' + +// Real WASM engine — no engine mock (ADR-005). Requires `pnpm build:wasm`. + +let runner: SimulationRunner | null = null + +afterEach(() => { + runner?.dispose() + runner = null +}) + +function vessel(over: Partial = {}): ScenarioDraftVesselDTO { + return { instanceId: 1, vesselId: 1, name: 'v', x: 0, y: 0, headingDeg: 0, ...over } +} + +function tickN(r: SimulationRunner, dt: number, n: number): void { + for (let i = 0; i < n; i++) r.tick(dt) +} + +describe('SimulationRunner', () => { + it('creates with an empty simulation at t=0', async () => { + runner = await SimulationRunner.create() + const s = runner.getState() + expect(s.t).toBe(0) + expect(s.vessels).toHaveLength(0) + }) + + it('loadScenario builds a fresh engine with the given vessels', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([ + vessel({ instanceId: 1, x: 10, y: 20, headingDeg: 0 }), + vessel({ instanceId: 2, x: -30, y: 5, headingDeg: 90 }), + ]) + const s = runner.getState() + expect(s.vessels).toHaveLength(2) + expect(s.vessels[0].x).toBeCloseTo(10, 3) + expect(s.vessels[1].psi).toBeCloseTo(Math.PI / 2, 3) + }) + + it('loadScenario twice rebuilds from scratch (no leftover vessels)', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 }), vessel({ instanceId: 2 })]) + await runner.loadScenario([vessel({ instanceId: 5, x: 1 })]) + const s = runner.getState() + expect(s.vessels).toHaveLength(1) + expect(s.vessels[0].id).toBe(5) + }) + + it('thruster command drives the vessel forward in surge', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 })]) + runner.setActuator(1, 'thruster', 0, 100, 0) + tickN(runner, 0.1, 100) + const v = runner.getState().vessels[0] + expect(v.u).toBeGreaterThan(0) + expect(v.x).toBeGreaterThan(0) + }) + + it('rudder deflection while making way produces a yaw rate', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 })]) + runner.setActuator(1, 'thruster', 0, 100, 0) + tickN(runner, 0.1, 50) + runner.setActuator(1, 'rudder', 0, 35) + tickN(runner, 0.1, 50) + expect(Math.abs(runner.getState().vessels[0].r)).toBeGreaterThan(0) + }) + + it('loadEnvironment with a current changes vessel motion vs calm water', async () => { + const calm = await SimulationRunner.create() + const flow = await SimulationRunner.create() + try { + await calm.loadScenario([vessel({ instanceId: 1 })]) + await flow.loadScenario([vessel({ instanceId: 1 })]) + const env = JSON.stringify({ + currentSeries: [[{ t: 0, speed: 5, dirNaut: 90 }]], + windSeries: [], + waveSeries: [], + }) + flow.loadEnvironment(env) + tickN(calm, 0.1, 100) + tickN(flow, 0.1, 100) + const a = calm.getState().vessels[0] + const b = flow.getState().vessels[0] + // Any measurable divergence proves the environment reaches the physics. + const diff = Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.v - b.v) + expect(diff).toBeGreaterThan(1e-6) + } finally { + calm.dispose() + flow.dispose() + } + }) + + it('loadEnvironment applies each spectrum type and tolerates malformed JSON', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 })]) + for (const spectrum of ['REGULAR', 'PIERSON', 'JONSWAP']) { + const env = JSON.stringify({ + currentSeries: [], + windSeries: [], + waveSeries: [{ t: 0, Hs: 2, Tp: 8, dirNaut: 180, spectrum, gamma: 3.3 }], + }) + expect(() => runner!.loadEnvironment(env)).not.toThrow() + } + expect(() => runner!.loadEnvironment('not-json')).not.toThrow() + expect(() => runner!.loadEnvironment('{}')).not.toThrow() + tickN(runner, 0.05, 5) + expect(Number.isFinite(runner.getState().vessels[0].z)).toBe(true) + }) + + it('reset does not throw and leaves the engine steppable', async () => { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 })]) + runner.setActuator(1, 'thruster', 0, 100, 0) + tickN(runner, 0.1, 20) + expect(() => runner!.reset()).not.toThrow() + tickN(runner, 0.1, 5) + expect(Number.isFinite(runner.getState().vessels[0].x)).toBe(true) + }) + + it('play runs the loop on a timer and stop halts it', async () => { + vi.useFakeTimers() + try { + runner = await SimulationRunner.create() + await runner.loadScenario([vessel({ instanceId: 1 })]) + const states: number[] = [] + expect(runner.running).toBe(false) + runner.play(undefined, (s) => states.push(s.t)) + expect(runner.running).toBe(true) + runner.play(0.05, () => { /* second call is a no-op while running */ }) + vi.advanceTimersByTime(160) // ~3 ticks at 50 ms + const countAfterRun = states.length + expect(countAfterRun).toBeGreaterThanOrEqual(2) + runner.stop() + expect(runner.running).toBe(false) + vi.advanceTimersByTime(200) + expect(states.length).toBe(countAfterRun) // no more ticks after stop + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/api/src/simulation/simulation-runner.ts b/apps/api/src/simulation/simulation-runner.ts new file mode 100644 index 0000000..ab0971f --- /dev/null +++ b/apps/api/src/simulation/simulation-runner.ts @@ -0,0 +1,141 @@ +import { createSimulationEngine, type SimulationEngine } from '@ymir/wasm' +import type { SimulationStateDTO, ScenarioDraftVesselDTO } from '@ymir/types' + +const DEG_TO_RAD = Math.PI / 180 +const DEFAULT_DT = 0.05 // 20 Hz +const LOOP_INTERVAL_MS = 50 + +/** Maps a scenario spectrum name to the engine's integer id (mirrors YmirBindings). */ +function spectrumId(spectrum: string | undefined): number { + if (spectrum === 'REGULAR') return 2 + if (spectrum === 'PIERSON') return 1 + return 0 // JONSWAP / default +} + +export interface SimulationRunnerOptions { + /** Injectable factory (defaults to the real @ymir/wasm engine). */ + engineFactory?: () => Promise +} + +/** + * Owns one isolated physics engine and its real-time tick loop. Applies + * commands and produces state snapshots. Pure runtime logic — knows nothing + * about worker_threads or WebSocket; the SimulationWorker bridges it to a port. + */ +export class SimulationRunner { + private engine: SimulationEngine + private readonly engineFactory: () => Promise + private timer: ReturnType | null = null + + private constructor(engine: SimulationEngine, factory: () => Promise) { + this.engine = engine + this.engineFactory = factory + } + + static async create(opts: SimulationRunnerOptions = {}): Promise { + const factory = opts.engineFactory ?? createSimulationEngine + const engine = await factory() + return new SimulationRunner(engine, factory) + } + + /** + * Replace the current simulation with a fresh engine holding the given + * vessels. Rebuilding from scratch is the only safe path — adding a vessel + * after stepping is undefined behaviour in the CVODE-based engine. + */ + async loadScenario(vessels: ScenarioDraftVesselDTO[]): Promise { + this.stop() + this.engine.delete() + this.engine = await this.engineFactory() + for (const v of vessels) { + this.engine.addVesselAt(v.instanceId, v.x, v.y, v.headingDeg * DEG_TO_RAD) + } + } + + setActuator( + vesselId: number, + deviceType: 'rudder' | 'thruster', + deviceId: number, + value: number, + value2 = 0, + ): void { + if (deviceType === 'rudder') { + this.engine.setRudderAngle(vesselId, deviceId, value) + } else { + this.engine.setThrusterCommand(vesselId, deviceId, value, value2) + } + } + + /** + * Load the environment timeline and apply the first sea state to the wave + * model. The JSON is validated here before reaching the engine — the native + * parser aborts on malformed input, so we never hand it invalid JSON. + */ + loadEnvironment(json: string): void { + if (!json || json === '{}') return + let env: { + waveSeries?: Array<{ Hs?: number; Tp?: number; dirNaut?: number; gamma?: number; spectrum?: string }> + } + try { + env = JSON.parse(json) + } catch { + return // malformed env — leave the sea calm, don't touch the engine + } + this.engine.loadEnvironment(json) + const kf = env.waveSeries?.[0] + if (kf) { + this.engine.setWaveConditions( + kf.Hs ?? 0, + kf.Tp ?? 8, + kf.dirNaut ?? 0, + kf.gamma ?? 3.3, + spectrumId(kf.spectrum), + ) + } + } + + /** Advance one step and return the resulting state. */ + tick(dt: number = DEFAULT_DT): SimulationStateDTO { + this.engine.step(dt) + return this.engine.getState() + } + + getState(): SimulationStateDTO { + return this.engine.getState() + } + + reset(): void { + this.engine.reset() + } + + /** Whether the tick loop is currently running. */ + get running(): boolean { + return this.timer !== null + } + + /** Start the real-time loop; `onState` is invoked with each tick's snapshot. */ + play(dt: number = DEFAULT_DT, onState: (state: SimulationStateDTO) => void): void { + if (this.timer !== null) return + this.timer = setInterval(() => { + try { + onState(this.tick(dt)) + } catch { + this.stop() + } + }, LOOP_INTERVAL_MS) + } + + /** Freeze the loop without discarding state. */ + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Stop the loop and free the engine. */ + dispose(): void { + this.stop() + this.engine.delete() + } +} diff --git a/apps/api/src/simulation/simulation-worker.entry.ts b/apps/api/src/simulation/simulation-worker.entry.ts new file mode 100644 index 0000000..e03e14a --- /dev/null +++ b/apps/api/src/simulation/simulation-worker.entry.ts @@ -0,0 +1,25 @@ +import { isMainThread, parentPort } from 'node:worker_threads' +import { SimulationRunner } from './simulation-runner.js' +import { attachRunnerToPort, type MessagePortLike } from './simulation-worker.js' +import type { WorkerEvent } from './worker-protocol.js' + +/** + * Worker_thread entrypoint. The SimulationManager spawns this file (one per + * simulation). Boots a SimulationRunner and bridges it to the parent port. + * Excluded from coverage — it requires a live worker_thread; the bridge logic + * it wires (attachRunnerToPort) is unit-tested via MessageChannel. + */ +if (!isMainThread && parentPort) { + const port = parentPort + SimulationRunner.create() + .then((runner) => { + attachRunnerToPort(port as unknown as MessagePortLike, runner) + port.postMessage({ type: 'ready' } satisfies WorkerEvent) + }) + .catch((err: unknown) => { + port.postMessage({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + } satisfies WorkerEvent) + }) +} diff --git a/apps/api/src/simulation/simulation-worker.test.ts b/apps/api/src/simulation/simulation-worker.test.ts new file mode 100644 index 0000000..c484e3a --- /dev/null +++ b/apps/api/src/simulation/simulation-worker.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { MessageChannel, type MessagePort } from 'node:worker_threads' +import { attachRunnerToPort, type MessagePortLike } from './simulation-worker.js' +import { SimulationRunner } from './simulation-runner.js' +import type { WorkerCommand, WorkerEvent } from './worker-protocol.js' + +// Bridge is exercised over a real MessageChannel with the real engine (ADR-005). +// The error-path test injects a throwing runner stub (runner logic, not engine). + +const cleanups: Array<() => void> = [] +afterEach(() => { + for (const c of cleanups.splice(0)) c() +}) + +/** Resolve with the first event matching `pred`, or reject on timeout. */ +function nextEvent(port: MessagePort, pred: (e: WorkerEvent) => boolean, ms = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout waiting for event')), ms) + port.on('message', (e: WorkerEvent) => { + if (pred(e)) { + clearTimeout(timer) + resolve(e) + } + }) + }) +} + +function send(port: MessagePort, cmd: WorkerCommand): void { + port.postMessage(cmd) +} + +describe('attachRunnerToPort', () => { + it('runs a scenario: play emits state, pause emits paused status', async () => { + const runner = await SimulationRunner.create() + const { port1, port2 } = new MessageChannel() + cleanups.push(() => { runner.dispose(); port1.close(); port2.close() }) + attachRunnerToPort(port1 as unknown as MessagePortLike, runner) + + send(port2, { type: 'loadScenario', vessels: [{ instanceId: 1, vesselId: 1, name: 'v', x: 0, y: 0, headingDeg: 0 }] }) + send(port2, { type: 'setActuator', vesselId: 1, deviceType: 'thruster', deviceId: 0, value: 100, value2: 0 }) + send(port2, { type: 'play' }) + + const running = await nextEvent(port2, (e) => e.type === 'status' && e.status === 'running') + expect(running.type).toBe('status') + const state = await nextEvent(port2, (e) => e.type === 'state') + expect(state.type).toBe('state') + if (state.type === 'state') { + expect(state.payload.vessels).toHaveLength(1) + } + + send(port2, { type: 'pause' }) + const paused = await nextEvent(port2, (e) => e.type === 'status' && e.status === 'paused') + expect(paused).toBeTruthy() + }) + + it('forwards reset without emitting an error', async () => { + const runner = await SimulationRunner.create() + const { port1, port2 } = new MessageChannel() + cleanups.push(() => { runner.dispose(); port1.close(); port2.close() }) + attachRunnerToPort(port1 as unknown as MessagePortLike, runner) + + send(port2, { type: 'loadScenario', vessels: [{ instanceId: 1, vesselId: 1, name: 'v', x: 0, y: 0, headingDeg: 0 }] }) + send(port2, { type: 'loadEnvironment', json: '{}' }) + send(port2, { type: 'reset' }) + + let errored = false + port2.on('message', (e: WorkerEvent) => { if (e.type === 'error') errored = true }) + await new Promise((r) => setTimeout(r, 200)) + expect(errored).toBe(false) + }) + + it('emits an error event when a command handler throws (Error and non-Error)', async () => { + // Runner stub whose method throws — exercises the bridge catch branch. + const throwingRunner = { + loadScenario: () => { throw new Error('boom') }, + reset: () => { throw 'plain-string' }, + } as unknown as SimulationRunner + const { port1, port2 } = new MessageChannel() + cleanups.push(() => { port1.close(); port2.close() }) + attachRunnerToPort(port1 as unknown as MessagePortLike, throwingRunner) + + send(port2, { type: 'loadScenario', vessels: [] }) + const e1 = await nextEvent(port2, (e) => e.type === 'error') + expect(e1.type === 'error' && e1.message).toBe('boom') + + send(port2, { type: 'reset' }) + const e2 = await nextEvent(port2, (e) => e.type === 'error' && e.message === 'plain-string') + expect(e2).toBeTruthy() + }) +}) diff --git a/apps/api/src/simulation/simulation-worker.ts b/apps/api/src/simulation/simulation-worker.ts new file mode 100644 index 0000000..eb987cf --- /dev/null +++ b/apps/api/src/simulation/simulation-worker.ts @@ -0,0 +1,52 @@ +import type { SimulationStateDTO } from '@ymir/types' +import type { SimulationRunner } from './simulation-runner.js' +import type { WorkerCommand, WorkerEvent } from './worker-protocol.js' + +/** Minimal port surface shared by worker_threads' parentPort and MessageChannel ports. */ +export interface MessagePortLike { + postMessage(value: unknown): void + on(event: 'message', listener: (value: WorkerCommand) => void): void +} + +/** + * Bridge a SimulationRunner to a message port: translate inbound WorkerCommands + * into runner calls and emit WorkerEvents (state/status/error) back. Kept free + * of worker_threads specifics so it can be driven by a MessageChannel in tests. + */ +export function attachRunnerToPort(port: MessagePortLike, runner: SimulationRunner): void { + const emit = (event: WorkerEvent): void => port.postMessage(event) + const emitState = (payload: SimulationStateDTO): void => emit({ type: 'state', payload }) + + port.on('message', (cmd: WorkerCommand) => { + void handleCommand(cmd) + }) + + async function handleCommand(cmd: WorkerCommand): Promise { + try { + switch (cmd.type) { + case 'loadScenario': + await runner.loadScenario(cmd.vessels) + break + case 'play': + runner.play(cmd.dt, (state) => emitState(state)) + emit({ type: 'status', status: 'running' }) + break + case 'pause': + runner.stop() + emit({ type: 'status', status: 'paused' }) + break + case 'reset': + runner.reset() + break + case 'setActuator': + runner.setActuator(cmd.vesselId, cmd.deviceType, cmd.deviceId, cmd.value, cmd.value2) + break + case 'loadEnvironment': + runner.loadEnvironment(cmd.json) + break + } + } catch (err) { + emit({ type: 'error', message: err instanceof Error ? err.message : String(err) }) + } + } +} diff --git a/apps/api/src/simulation/worker-protocol.ts b/apps/api/src/simulation/worker-protocol.ts new file mode 100644 index 0000000..a647789 --- /dev/null +++ b/apps/api/src/simulation/worker-protocol.ts @@ -0,0 +1,28 @@ +import type { SimulationStateDTO, VesselConfigDTO, ScenarioDraftVesselDTO } from '@ymir/types' + +/** + * Internal message protocol between the main thread (SimulationManager) and a + * SimulationWorker running in a worker_thread. Distinct from the network + * ClientMessage/ServerMessage: these carry no sim_id (the worker IS one sim). + */ +export type WorkerCommand = + | { type: 'loadScenario'; vessels: ScenarioDraftVesselDTO[] } + | { type: 'play'; dt?: number } + | { type: 'pause' } + | { type: 'reset' } + | { + type: 'setActuator' + vesselId: number + deviceType: 'rudder' | 'thruster' + deviceId: number + value: number + value2?: number + } + | { type: 'loadEnvironment'; json: string } + +export type WorkerEvent = + | { type: 'ready' } + | { type: 'state'; payload: SimulationStateDTO } + | { type: 'status'; status: 'running' | 'paused' | 'ended' } + | { type: 'vesselConfig'; payload: VesselConfigDTO } + | { type: 'error'; message: string } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..a468bc9 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + // Real WASM engine boots asynchronously and runs physics — allow headroom. + testTimeout: 30000, + hookTimeout: 30000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/simulation/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + // Worker/thread entrypoints need a live worker_thread; the logic they + // wire is covered via MessageChannel unit tests. + 'src/simulation/simulation-worker.entry.ts', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22d5856..672b908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@ymir/types': specifier: workspace:* version: link:../../packages/types + '@ymir/wasm': + specifier: workspace:* + version: link:../../packages/wasm better-sqlite3: specifier: ^11.0.0 version: 11.10.0 @@ -39,6 +42,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.20.0 + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) drizzle-kit: specifier: ^0.30.0 version: 0.30.6 @@ -48,6 +54,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(tsx@4.22.4)) apps/web: dependencies: