openvibers/openvibe.games Commits
M1 persistence: Games runs on PostgreSQL 18 + Valkey through openvibe-sdk (ADR-0007 decision 5). SQLite is gone (no data is restored; a new schema from scratch, no schema-version history).
Changes
nnew file mode 100644@@@ -0,0 +1,22 @@++# Names only; never commit real values. Copy to .env (gitignored) and fill in.++++# ── Persistence (ADR-0007 decision 5: PostgreSQL 18 + Valkey through openvibe-sdk) ──++# Pooled PostgreSQL URL (through PgBouncer, transaction mode). Required in production.++DATABASE_URL=++# Owner-role direct connection migrations run on (a session, not PgBouncer). Required with DATABASE_URL.++DATABASE_DIRECT_URL=++# Development only: embedded PGlite directory when DATABASE_URL is unset (never production).++DATABASE_DIR=data++# Optional: another SQL migrations directory (default: @openvibe/persistence's own, from any working directory).++GAMES_MIGRATIONS_DIR=++# The place this instance serves; world rows and meta are keyed by it.++GAMES_PLACE_ID=scraplandia++++# ── Valkey (optional): shared per-actor rate-limit counters across instances ──++VALKEY_URL=++VALKEY_PREFIX=ov:games:++++# ── Server ──++PORT=8000++HOST=0.0.0.0++MAP_PATH=data/map.json@@@ -4,10 +4,7 @@ dist-types/*.tsbuildinfo.env*.log--# Runtime SQLite state, at the repo root or in any app (never a fixture).--data/*.db--data/*.db-*--apps/*/data/*.db--apps/*/data/*.db-*++# Embedded development database (PGlite under the data dir), never a fixture.++**/data/pglite/.DS_Storescratch/@@@ -7,5 +7,5 @@ useDefault = true[[allowlists]]description = "Made-up player/session tokens in unit tests"condition = "AND"--paths = ['''^packages/(persistence/src/sqlite/sqliteStore|protocol/src/codec)\.test\.ts$''']++paths = ['''^packages/(persistence/src/pg/pgStore|protocol/src/codec)\.test\.ts$''']regexes = ['''^(tok_12345678|abcdefgh12345678)$''']nnew file mode 100644@@@ -0,0 +1,5 @@++# Do not auto-install missing peer dependencies: openvibe-sdk marks its storage/queue backends as++# optional peers, and auto-installing all of them pulled a native database driver Games no longer uses++# into the lockfile. The backends Games needs (pg, @electric-sql/pglite, iovalkey) are declared++# dependencies, so the SDK still resolves them.++auto-install-peers=false@@@ -28,14 +28,14 @@\ | /gameplay|-- persistence (DTOs; sqlite impl behind repository interfaces)++ persistence (DTOs; PostgreSQL impl behind repository interfaces; migrations)|apps/server apps/client(authoritative) (Babylon render + prediction + HUD)```Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only--`apps/server` and `packages/persistence` know SQLite exists. `packages/*`++`apps/server` and `packages/persistence` know PostgreSQL (openvibe-sdk/db) exists. `packages/*`never import browser-only APIs (the physics adapter's NullEngine path isNode-safe; the client hands it a rendered scene instead).@@@ -85,19 +85,27 @@ Node-safe; the client hands it a rendered scene instead).4. Sync awake prop transforms into entity records; settle/wake transitions5. Crafting queues (tick-based completion, output-space aware)6. Replication (every 2nd tick): interest diff + snapshot per session--7. Periodic batched persistence flush (dirty entities + dirty players)++7. Periodic write-behind checkpoint (a dirty snapshot handed to the flusher; the tick never awaits I/O)## Persistence--SQLite (WAL) behind `PersistenceStore` repositories: `world_entities`--(props/resources with motion + kind-specific state JSON), `players` (token,--transform, inventory JSON), `meta` (schema version, world-seeded flag).--Dirty tracking batches writes; flush on interval, on settle, and on shutdown.--Each flush is one transaction, together with the outbox events describing--it. First boot seeds from the world definition; afterwards the DB is the--world's source of truth. Platform tables: `identity_adoptions`, `mods`,--`mod_grants`, `mod_placements`, `mod_audit`, `media_mirrors`, and the SDK's--`event_outbox`. The simulation never reads them.++PostgreSQL 18 through openvibe-sdk/db, behind `PersistenceStore` repositories (the SDK's async handle++and `sql` fragments; migrations in `packages/persistence/migrations/0001_initial.sql` applied at boot++by the SDK migrator as the owner role). The schema: `places`, `world_meta`, `world_entities`++(props/resources/NPCs with motion + kind-specific state JSON), `world_constraints`, `characters`++(inventory, skills, friends, reputation, unlocks, stats, armor, active job), `mods`, `mod_grants`,++`mod_placements`, `mod_audit`, `media_mirrors`, `identity_adoptions`, `account_data_events`, and the++SDK's `event_outbox` and `token_revocations`. JSON columns are jsonb and round-trip as objects; a++local guest account is keyed by `guest:` + sha256(their token), never the raw token.++++**The tick never awaits I/O.** It takes a copy-on-write snapshot of the dirty rows++(`world.takeDirty()`, `npcs.takeDirty()`, dirty sessions, env/markets) and the write-behind flusher++(`apps/server/src/game/flusher.ts`) persists it in one transaction, one flush in flight per instance.++A checkpoint while one is running is coalesced (the dirty flags stay set), a failed transaction++restores the snapshot's dirtiness and is retried, and shutdown drains the queue before the database++closes. Batch writes are multi-row `INSERT … ON CONFLICT` in chunks of at most 500 rows. First boot++seeds from the world definition; afterwards the database is the world's source of truth. The++simulation never reads the platform tables.## Platform boundary@@@ -9,12 +9,13 @@ extraction risk — running against an authoritative dedicated server.**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·--WebSocket protocol · SQLite persistence · pnpm monorepo.++WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.## Owns- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and-- props, in `world.db` (SQLite, forward-only migrations; `SCHEMA_VERSION` 11)++ props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under++ `packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)- the map editor and its assets, the mod registry (`games-content@1` packs, grants, audit) and the`games.*` events- the game WebSocket protocol and the portal, `/play` and `/editor` pages@@@ -30,8 +31,9 @@ WebSocket protocol · SQLite persistence · pnpm monorepo.- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)--- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.12.0 and `openvibe-shared` v1.23.0 (pinned by release-- tarball in `apps/server/package.json`), Babylon.js, Havok, better-sqlite3++- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release++ tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in++ development), Valkey (`iovalkey`, optional)## Repository layout@@@ -45,7 +47,7 @@ packages/content/ Data-driven item/recipe/world definitions + validation registrygameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entitiesphysics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)-- persistence/ DTOs, repository interfaces, SQLite implementation++ persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrationsdocs/adr/ Architecture decision records```@@@ -104,27 +106,34 @@ freeze. Props are protected: only you and players you trust can move them.```bashpnpm build--STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openvibe/server start++DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/client/dist PORT=8000 pnpm --filter @openvibe/server start```++`DATABASE_URL` (pooled, through PgBouncer) serves; migrations run at boot on `DATABASE_DIRECT_URL`++(the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses++an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process.++`GAMES_PLACE_ID` (default `scraplandia`) is the place every world row belongs to; `VALKEY_URL`++(optional) makes the per-actor limits shared across instances. See [.env.example](.env.example).++The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the gameWebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` isthe game and `/play` redirects there. Any other path that is neither a file of the build nor a routeanswers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text--for a missing asset), never the portal with a 200. `/api/ready` is 200 only while `world.db` answers a query and the simulation++for a missing asset), never the portal with a 200. `/api/ready` is 200 only while the database answers a real round trip++(`db.ready()`) and the simulationhas ticked within the last 5 s, otherwise 503 naming the failed check (`status`--`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,--`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).++`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `STATIC_DIR`,++`MAX_PLAYERS`, `LOG_LEVEL`, `GAMES_PLACE_ID`, the database variables above (platform variables below).Deployed at https://openvibe.games (nginx TLS termination → server on 127.0.0.1:8000,systemd unit `openvibe-games.service` from [deploy/openvibe-games.service](deploy/openvibe-games.service),--env file `/etc/openvibe/games.env`, database `/opt/openvibe.games/data/world.db`;++env file `/etc/openvibe/games.env` (which holds `DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL`);play.openvibe.games Host-routes to the same process).Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs`ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull asthe checkout owner, `pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`,--better-sqlite3 loaded under the host's Node, the restart, `/api/ready`, and on failure the checkout++the restart (the SDK migrator applies any new migration as the owner before serving), `/api/ready`, and on failure the checkoutrestored, reinstalled and rebuilt and the server restarted again. Players online are reported andreconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`,`DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass@@@ -134,8 +143,8 @@ wrapper runs `deploy/scripts/deploy-legacy.sh`: the procedure as it was run by h`sudo systemctl restart openvibe-games`).Rollback: `sudo deploy/scripts/deploy.sh --rollback` (`ovhost rollback games`), which rebuilds the previous--commit. One blocker: a build refuses a `world.db` whose `meta.schema_version` is newer than its own, so going--back past a schema bump fails at boot.++commit. Migrations are additive and applied at boot, so an older build starts on the newer schema; the SDK++migrator refuses a migration file that was edited after it ran (write a new one instead).## Platform integration (OpenVibe network)@@@ -153,7 +162,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjectsmade for a player (`/api/auth/me`, code and FedCM exchanges) keep using theplayer's own token.- **Events** (OpenVibe.Events, through a transactional outbox in-- `world.db`, table `event_outbox`): `games.player.joined|left`,++ PostgreSQL, table `event_outbox`): `games.player.joined|left`,`games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved`(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and`games.mod.installed|enabled|disabled|grants_changed|revoked`. There is no@@@ -3,17 +3,22 @@"stage": "stable","code": true,"product": "Scraplandia",-- "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; better-sqlite3",++ "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional","port": 8000,"domain": "openvibe.games, play.openvibe.games",-- "contracts": "openvibe-contracts v0.71.0 (apps/server/package.json)",-- "capabilities": ["games.mod.manage", "games.mod.read", "games.world.announce", "games.prop.place"],++ "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",++ "capabilities": [++ "games.mod.manage",++ "games.mod.read",++ "games.world.announce",++ "games.prop.place"++ ],"deployed": true,"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.","features": ["authoritative multiplayer physics sandbox (Havok on the server)","Source-style movement, physgun building, gathering, crafting, skills",-- "persistent world, characters and props in SQLite",++ "persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)","map editor with assets mirrored to OpenVibe.Media","declarative mods (games-content@1) with per-install grants and audit","players keyed by Network subjects",@@@ -22,5 +27,5 @@"the OpenVibe Frame from its own pinned copy","per-actor limits on capability-boundary writes (openvibe-sdk/limits)"],-- "updated": "2026-09-28"++ "updated": "2026-09-29"}@@@ -83,7 +83,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/mainenv: {...process.env,PORT: String(PORT),-- DB_PATH: join(dir, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: dir,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',MAP_PATH: mapPath,EDITOR_KEY: 'test-admin-key',@@@ -696,7 +699,10 @@ async function withMap(env: {...process.env,PORT: String(port),-- DB_PATH: join(d, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: d,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',MAP_PATH: mp,EDITOR_KEY: 'test-admin-key',@@@ -768,7 +774,10 @@ section('O. Live static reconciliation reaches the running server')env: {...process.env,PORT: String(port),-- DB_PATH: join(d, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: d,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',MAP_PATH: mp,EDITOR_KEY: 'test-admin-key',@@@ -1022,7 +1031,10 @@ section('U. Live reconciliation touches only what changed')env: {...process.env,PORT: String(port),-- DB_PATH: join(d, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: d,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',MAP_PATH: mp,EDITOR_KEY: 'test-admin-key',@@@ -64,7 +64,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/mainenv: {...process.env,PORT: String(PORT),-- DB_PATH: join(dir, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: dir,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',MAP_PATH: mapPath,EDITOR_KEY: KEY,@@@ -22,7 +22,10 @@ function startServer(): Promise<void> {env: {...process.env,PORT: String(PORT),-- DB_PATH: join(dir, 'world.db'),++ NODE_ENV: 'test',++ DATABASE_DIR: dir,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',STATIC_DIR: 'apps/client/dist',},stdio: ['ignore', 'pipe', 'pipe'],@@@ -95,14 +98,16 @@ async function main(): Promise<void> {// Look down to check the first-person body (drive input tracker directly).await page.evaluate(() => {-- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = -1.2++ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =++ -1.2})await page.waitForTimeout(700)await shot(page, '04-look-down-body')console.log('phase: physgun E2E (walk to crates, grab, verify no freeze)')await page.evaluate(() => {-- const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } }).__openvibe++ const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } })++ .__openvibew.input.pitch = 0w.input.yaw = 0})@@@ -185,7 +190,8 @@ async function main(): Promise<void> {])console.log(` page responsive after grab: ${alive}`)const held = await page.evaluate(() => {-- const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } }).__openvibe++ const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } })++ .__openvibereturn w.state.heldBy.size})console.log(` beams active (heldBy size): ${held}`)@@@ -193,7 +199,8 @@ async function main(): Promise<void> {// RMB while holding = freeze: held target goes 'frozen' and the beam drops.const heldId = await page.evaluate(() => {-- const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } }).__openvibe++ const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } })++ .__openvibereturn [...w.state.heldBy.keys()][0] ?? null})await page.evaluate(() => {@@@ -242,7 +249,8 @@ async function main(): Promise<void> {await page.waitForTimeout(600)// Lift the crate off the ground (friction fights the last few degrees).await page.evaluate(() => {-- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = 0.45++ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =++ 0.45})await page.waitForTimeout(800)await page.keyboard.down('KeyE')@@@ -310,7 +318,8 @@ async function main(): Promise<void> {// Beam fires even at nothing: aim at the sky, hold LMB, expect the dim// searching ray from the muzzle (GMod always-on beam).await page.evaluate(() => {-- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = 0.5++ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =++ 0.5})await page.waitForTimeout(200)await page.mouse.down()@@@ -320,7 +329,8 @@ async function main(): Promise<void> {void dbgawait page.evaluate(() => {-- const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } }).__openvibe++ const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } })++ .__openvibew.input.pitch = -0.15w.input.yaw = Math.PI})@@@ -332,7 +342,8 @@ async function main(): Promise<void> {await page.keyboard.down('KeyC')await page.waitForTimeout(900)await page.evaluate(() => {-- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = -1.1++ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =++ -1.1})await page.waitForTimeout(400)await shot(page, '09-crouch-lookdown')@@@ -7,7 +7,7 @@"dev": "tsx watch --env-file-if-exists=.env src/main.ts","start": "tsx src/main.ts","build": "tsc --build",-- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:better-sqlite3 --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""++ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""},"dependencies": {"@babylonjs/havok": "^1.3.10",@@@ -17,16 +17,17 @@"@openvibe/physics": "workspace:*","@openvibe/protocol": "workspace:*","@openvibe/shared": "workspace:*",++ "@electric-sql/pglite": "^0.5.8","ajv": "^8.17.1",-- "better-sqlite3": "^11.9.1",++ "iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",++ "pg": "^8.23.0","tsx": "^4.19.3","ws": "^8.18.1"},"devDependencies": {-- "@types/better-sqlite3": "^7.6.12","@types/ws": "^8.18.0","esbuild": "^0.25.2"}@@@ -32,7 +32,6 @@ import { encodeHeights } from '@openvibe/content'const PORT = 18123const URL = `ws://127.0.0.1:${PORT}/ws`const dir = mkdtempSync(join(tmpdir(), 'openvibe-slice-'))--const dbPath = join(dir, 'world.db')let server: ChildProcess | null = null@@@ -41,33 +40,45 @@ function startServer(): Promise<void> {// map with the resource nodes its gameplay phases depend on.const FIX_SUB = 32const flat = new Float32Array((FIX_SUB + 1) * (FIX_SUB + 1))++ // A v2 document (map v1 was deleted): one authored flat terrain patch plus the++ // resource nodes and props the gameplay phases depend on. Every document object++ // carries a stable id.const fixtureMap = {-- v: 1,-- halfExtent: 64,-- sub: FIX_SUB,-- heights: encodeHeights(flat),++ v: 2,++ terrains: [++ {++ id: 'ground',++ name: 'slice ground',++ pos: [0, 0, 0],++ halfExtent: 64,++ sub: FIX_SUB,++ heights: encodeHeights(flat),++ },++ ],statics: [],nodes: [-- { node: 'oak_tree', pos: [24, 0, 24] },-- { node: 'branch_pile', pos: [-24, 0, 2] },-- { node: 'loose_stones', pos: [-24, 0, -2] },-- { node: 'scrap_pile', pos: [-28, 0, 4] },-- { node: 'berry_bush', pos: [22, 0, 18] },++ { id: 'n-oak', node: 'oak_tree', pos: [24, 0, 24] },++ { id: 'n-branch', node: 'branch_pile', pos: [-24, 0, 2] },++ { id: 'n-stones', node: 'loose_stones', pos: [-24, 0, -2] },++ { id: 'n-scrap', node: 'scrap_pile', pos: [-28, 0, 4] },++ { id: 'n-berry', node: 'berry_bush', pos: [22, 0, 18] },],props: [-- { item: 'merchant_stall', pos: [12, 0.6, 12.8], yaw: 3.14 },++ { id: 'pr-stall', item: 'merchant_stall', pos: [12, 0.6, 12.8], yaw: 3.14 },// Stage 4: an authored production yard outside the north gate.-- { item: 'sawmill', pos: [8, 0.7, 31], yaw: 0 },-- { item: 'cart_chassis', pos: [-6, 0.8, 30], yaw: 0 },-- { item: 'cart_wheel', pos: [-7.6, 0.6, 30], yaw: 0 },-- { item: 'cart_wheel', pos: [-4.4, 0.6, 30], yaw: 0 },-- { item: 'scrap_generator', pos: [10, 0.7, 31], yaw: 0 },-- { item: 'wooden_crate', pos: [2, 1.0, 25], yaw: 0.3 },-- { item: 'wooden_crate', pos: [2.2, 1.8, 25.1], yaw: 0.9 },-- { item: 'wooden_crate', pos: [-2, 1.0, 27], yaw: 0.1 },-- { item: 'metal_barrel', pos: [-1, 1.0, 24], yaw: 0 },-- { item: 'metal_barrel', pos: [-4, 1.0, 29], yaw: 0 },++ { id: 'pr-sawmill', item: 'sawmill', pos: [8, 0.7, 31], yaw: 0 },++ { id: 'pr-chassis', item: 'cart_chassis', pos: [-6, 0.8, 30], yaw: 0 },++ { id: 'pr-wheel-a', item: 'cart_wheel', pos: [-7.6, 0.6, 30], yaw: 0 },++ { id: 'pr-wheel-b', item: 'cart_wheel', pos: [-4.4, 0.6, 30], yaw: 0 },++ { id: 'pr-generator', item: 'scrap_generator', pos: [10, 0.7, 31], yaw: 0 },++ { id: 'pr-crate-a', item: 'wooden_crate', pos: [2, 1.0, 25], yaw: 0.3 },++ { id: 'pr-crate-b', item: 'wooden_crate', pos: [2.2, 1.8, 25.1], yaw: 0.9 },++ { id: 'pr-crate-c', item: 'wooden_crate', pos: [-2, 1.0, 27], yaw: 0.1 },++ { id: 'pr-barrel-a', item: 'metal_barrel', pos: [-1, 1.0, 24], yaw: 0 },++ { id: 'pr-barrel-b', item: 'metal_barrel', pos: [-4, 1.0, 29], yaw: 0 },],++ lights: [],++ zones: [],}const mapPath = join(tmpdir(), `hq-slice-map-${Date.now()}.json`)writeFileSync(mapPath, JSON.stringify(fixtureMap))@@@ -75,8 +86,13 @@ function startServer(): Promise<void> {server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {env: {...process.env,++ // Embedded PGlite under this temp dir (no DATABASE_URL in development); the restart test++ // reuses the same directory, so the world survives the restart.++ NODE_ENV: 'test',PORT: String(PORT),-- DB_PATH: dbPath,++ DATABASE_DIR: dir,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',EVENT_INTERVAL_SCALE: '0.05',MAP_PATH: mapPath,},nnew file mode 100644@@@ -0,0 +1,39 @@++import { existsSync } from 'node:fs'++import { dirname, isAbsolute, join } from 'node:path'++import { fileURLToPath } from 'node:url'++import { afterEach, describe, expect, it } from 'vitest'++import { MIGRATIONS_DIR } from '@openvibe/persistence'++import { loadConfig } from './config.js'++++/**++ * `pnpm dev:server` runs with cwd apps/server, so a cwd-relative migrations default applied 0++ * migrations there and the server died on the first query. The default is the persistence package's++ * absolute directory; GAMES_MIGRATIONS_DIR still overrides it.++ */++++/** apps/server (the dev cwd), from this test file's location. */++const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')++++const originalCwd = process.cwd()++afterEach(() => process.chdir(originalCwd))++++describe('loadConfig migrationsDir', () => {++ it('defaults to the persistence package absolute migrations dir', () => {++ const { migrationsDir } = loadConfig({}).db++ expect(migrationsDir).toBe(MIGRATIONS_DIR)++ expect(isAbsolute(migrationsDir)).toBe(true)++ expect(migrationsDir.endsWith('migrations')).toBe(true)++ })++++ it('lets GAMES_MIGRATIONS_DIR win', () => {++ const { migrationsDir } = loadConfig({ GAMES_MIGRATIONS_DIR: '/somewhere/else' }).db++ expect(migrationsDir).toBe('/somewhere/else')++ })++++ it('resolves from any cwd, like apps/server as the dev cwd', () => {++ process.chdir(SERVER_DIR)++ const { migrationsDir } = loadConfig({}).db++ expect(migrationsDir).toBe(MIGRATIONS_DIR)++ expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)++ })++})@@@ -1,7 +1,29 @@++import { MIGRATIONS_DIR } from '@openvibe/persistence'++++export interface DbConfig {++ /** Pooled PostgreSQL URL (through PgBouncer); null in development, where an embedded PGlite is used. */++ url: string | null++ /** Owner-role direct URL migrations run on; required when `url` is set. */++ directUrl: string | null++ /** DATABASE_DIR: where the development PGlite database lives (default ./data). */++ dataDir: string++ /**++ * The SQL migrations directory. Defaults to the persistence package's absolute migrations dir, so it++ * resolves the same from any working directory; GAMES_MIGRATIONS_DIR overrides it.++ */++ migrationsDir: string++ isProduction: boolean++ /** Shared Valkey (limits, and M4 leases); null = in-process counters. */++ valkeyUrl: string | null++ valkeyPrefix: string++}++export interface ServerConfig {port: numberhost: string-- dbPath: string++ /** GAMES_PLACE_ID: the place every world row on this instance belongs to. */++ placeId: string++ db: DbConfig/** Directory of built client assets to serve, or null for API/WS only. */staticDir: string | nullmapPath: string@@@ -88,7 +110,16 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {return {port: intEnv(env, 'PORT', 8000),host: env.HOST ?? '0.0.0.0',-- dbPath: env.DB_PATH ?? 'data/world.db',++ placeId: env.GAMES_PLACE_ID ?? 'scraplandia',++ db: {++ url: env.DATABASE_URL ?? null,++ directUrl: env.DATABASE_DIRECT_URL ?? null,++ dataDir: env.DATABASE_DIR ?? 'data',++ migrationsDir: env.GAMES_MIGRATIONS_DIR ?? MIGRATIONS_DIR,++ isProduction: env.NODE_ENV === 'production',++ valkeyUrl: env.VALKEY_URL ?? null,++ valkeyPrefix: env.VALKEY_PREFIX ?? 'ov:games:',++ },staticDir: env.STATIC_DIR ?? null,mapPath: env.MAP_PATH ?? 'data/map.json',/** Fallback admin secret for the map editor (until openvibe.network SSO). */nnew file mode 100644@@@ -0,0 +1,43 @@++import { mkdirSync } from 'node:fs'++import { join } from 'node:path'++import { createDb, type Db } from 'openvibe-sdk/db'++import type { Logger } from '@openvibe/shared'++import type { DbConfig } from './config.js'++++/**++ * The serving handle (ADR-0007 decision 5, ADR-035): DATABASE_URL through PgBouncer, or an embedded++ * PGlite database under DATABASE_DIR in development. Migrations run first, as the owner++ * (DATABASE_DIRECT_URL) against real PostgreSQL, or on the embedded handle.++ */++export async function openDb(config: DbConfig, log: Logger): Promise<Db> {++ const sdkLog = {++ warn: (m: string) => log.warn(m),++ error: (m: string) => log.error(m),++ log: (m: string) => log.info(m),++ }++ if (!config.url) {++ if (config.isProduction) {++ throw new Error('DATABASE_URL is not set: production serves from PostgreSQL')++ }++ const dir = join(config.dataDir, 'pglite')++ log.warn('DATABASE_URL unset: embedded PGlite database (development only, one process)', {++ dir,++ })++ mkdirSync(dir, { recursive: true })++ const db = createDb({ pglite: dir, service: 'games', log: sdkLog })++ await db.migrate({ dir: config.migrationsDir, log: sdkLog })++ return db++ }++ if (!config.directUrl) {++ throw new Error(++ 'DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection',++ )++ }++ const owner = createDb({ url: config.directUrl, service: 'games-migrate', max: 1, log: sdkLog })++ try {++ await owner.migrate({ dir: config.migrationsDir, log: sdkLog })++ } finally {++ await owner.close()++ }++ return createDb({ url: config.url, service: 'games', log: sdkLog })++}nnew file mode 100644@@@ -0,0 +1,457 @@++/**++ * The write-behind flusher (ADR-0007 decision 5): the tick hands over a copy-on-write snapshot and++ * never awaits I/O; one transaction runs at a time; a failed transaction restores the dirtiness; a++ * disconnect save lands after an earlier checkpoint of the same character; shutdown drains.++ */++import { describe, expect, it } from 'vitest'++import { createContent } from '@openvibe/content'++import { openTestStore } from '@openvibe/persistence/testing'++import type {++ ConstraintDto,++ PersistenceStore,++ PlayerDto,++ WorldEntityDto,++} from '@openvibe/persistence'++import type { ConstraintId, BodyId, PhysicsWorld } from '@openvibe/physics'++import { createConsoleLogger } from '@openvibe/shared'++import { quat, vec3 } from '@openvibe/shared'++import { sql, type Tx } from 'openvibe-sdk/db'++import { createPgOutbox } from 'openvibe-sdk/events'++import { ServerMetrics } from '../observability/metrics.js'++import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'++import type { EventPlayer, ProgressSnapshot } from '../platform/gameEvents.js'++import { RegionTracker } from '@openvibe/gameplay'++import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'++import { GameWorld } from './gameWorld.js'++import { NpcManager } from './npcManager.js'++++const log = createConsoleLogger({ app: 'flusher-test' }, 'error')++++/** A PhysicsWorld that keeps no simulation — enough to build a GameWorld and spawn entities. */++function stubPhysics(): PhysicsWorld {++ let n = 0++ const id = (p: string) => `${p}${++n}` as unknown as BodyId++ return {++ step: () => {},++ addBody: () => id('b'),++ removeBody: () => {},++ getTransform: () => {},++ setTransform: () => {},++ setMotionType: () => {},++ getLinearVelocity: () => {},++ setLinearVelocity: () => {},++ getAngularVelocity: () => {},++ setAngularVelocity: () => {},++ isSettled: () => true,++ wake: () => {},++ addConstraint: () => `c${++n}` as unknown as ConstraintId,++ removeConstraint: () => {},++ setConstraintMotor: () => {},++ applyForce: () => {},++ raycast: () => null,++ sweepCapsule: () => null,++ dispose: () => {},++ }++}++++function entityDto(id: string): WorldEntityDto {++ return {++ id,++ kind: 'prop',++ defId: 'wooden_crate',++ ownerId: null,++ pos: [1, 2, 3],++ rot: [0, 0, 0, 1],++ motion: 'frozen',++ state: null,++ updatedAt: 1,++ }++}++++function constraintDto(id: string): ConstraintDto {++ return { id, type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 1 }++}++++function playerDto(id: string, name: string, token = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'): PlayerDto {++ return {++ id,++ token,++ name,++ pos: [0, 1, 0],++ yaw: 0,++ inventory: { size: 24, hotbar: 6, slots: [] },++ skills: {},++ friends: [],++ appearance: null,++ charSlot: 0,++ armor: null,++ reputation: {},++ unlocks: [],++ activeJob: null,++ stats: null,++ updatedAt: 1,++ }++}++++const fakeSession = {} as unknown as FlushCharacter['session']++const eventPlayerOf = (id: string, name: string, subjectId: string | null = null): EventPlayer => ({++ playerId: id,++ slot: 0,++ name,++ subjectId,++})++const emptyProgress: ProgressSnapshot = { levels: {}, unlocks: [] }++++function character(dto: PlayerDto): FlushCharacter {++ return {++ session: fakeSession,++ dto,++ player: eventPlayerOf(dto.id, dto.name, dto.subjectId ?? null),++ progress: emptyProgress,++ }++}++++function payload(over: Partial<FlushPayload> = {}): FlushPayload {++ return {++ reason: 'checkpoint',++ leaving: false,++ worldSaved: false,++ meta: {},++ entityUpserts: [],++ entityDeletes: [],++ npcUpserts: [],++ constraintUpserts: [],++ constraintDeletes: [],++ characters: [],++ worldSavedCount: 0,++ restore: () => {},++ ...over,++ }++}++++/** Wraps a store, delaying every transaction so a checkpoint's job stays in flight. */++function slowStore(store: PersistenceStore, ms: number): PersistenceStore {++ const wrapped = Object.create(store) as PersistenceStore++ Object.defineProperty(wrapped, 'transaction', {++ value: async <T>(fn: (t: Tx) => Promise<T>): Promise<T> => {++ await new Promise((r) => setTimeout(r, ms))++ return store.transaction(fn)++ },++ })++ return wrapped++}++++/** Counts transactions and lets one (or the next) fail. */++function flakyStore(++ store: PersistenceStore,++ fail: () => boolean,++): { store: PersistenceStore; txCount: () => number } {++ let count = 0++ const wrapped = Object.create(store) as PersistenceStore++ Object.defineProperty(wrapped, 'transaction', {++ value: async <T>(fn: (t: Tx) => Promise<T>): Promise<T> => {++ count++++ if (fail()) throw new Error('db down')++ return store.transaction(fn)++ },++ })++ return { store: wrapped, txCount: () => count }++}++++function fakeEvents() {++ let id = 0++ let seq = 0++ return {++ prepare: (envelope: Record<string, unknown>) => ({ ...envelope, event_id: `evt_${++id}` }),++ publish: async (envelopes: unknown) => {++ const list = Array.isArray(envelopes) ? envelopes : [envelopes]++ return {++ results: list.map((e) => ({ event_id: (e as { event_id: string }).event_id, seq: ++seq })),++ }++ },++ }++}++++describe('write-behind flusher', () => {++ it('a final (shutdown) checkpoint behind one in flight is queued, not coalesced', async () => {++ const store = await openTestStore()++ const flusher = new Flusher(slowStore(store, 100), new ServerMetrics(), log, undefined)++ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('early')] }))++ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('coalesced')] }))++ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('final')] }), { final: true })++ expect(await flusher.drain()).toBe(true)++ const ids = (await store.worldEntities.loadAll()).map((e) => e.id).sort()++ expect(ids).toEqual(['early', 'final'])++ })++++ it('drain gives up at its deadline while a transaction hangs', async () => {++ const store = await openTestStore()++ const flusher = new Flusher(slowStore(store, 2000), new ServerMetrics(), log, undefined)++ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('slow')] }))++ const t0 = performance.now()++ expect(await flusher.drain(100)).toBe(false)++ expect(performance.now() - t0).toBeLessThan(1000)++ expect(await flusher.drain()).toBe(true)++ })++++ it('a failed disconnect save is retried in place until it lands', async () => {++ const store = await openTestStore()++ let failuresLeft = 2++ const { store: flaky, txCount } = flakyStore(store, () => failuresLeft-- > 0)++ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)++ flusher.savePlayer(++ payload({ characters: [character(playerDto('p-retry', 'Retry'))], leaving: true }),++ () => false,++ [10, 10, 10],++ )++ expect(await flusher.drain()).toBe(true)++ expect(txCount()).toBe(3)++ expect((await store.players.findById('p-retry'))?.name).toBe('Retry')++ })++++ it('a failed disconnect save stops retrying once the character is back online', async () => {++ const store = await openTestStore()++ const { store: flaky, txCount } = flakyStore(store, () => true)++ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)++ let restored = 0++ flusher.savePlayer(++ payload({ characters: [character(playerDto('p-back', 'Back'))], restore: () => restored++ }),++ () => true,++ [10, 10, 10],++ )++ expect(await flusher.drain()).toBe(true)++ expect(txCount()).toBe(1)++ expect(restored).toBe(1)++ })++++ it('(a) checkpoint returns synchronously while a slow transaction is pending', async () => {++ const store = await openTestStore()++ const metrics = new ServerMetrics()++ const flusher = new Flusher(slowStore(store, 150), metrics, log, undefined)++ let built = 0++ const t0 = performance.now()++ flusher.checkpoint(() => {++ built++++ return payload({ entityUpserts: [entityDto('e1')] })++ })++ const elapsed = performance.now() - t0++ expect(elapsed).toBeLessThan(50) // returned before the transaction finished++ expect(built).toBe(1)++ expect(flusher.pending).toBe(1)++ await flusher.drain()++ expect(await store.worldEntities.loadAll()).toHaveLength(1)++ expect(metrics.persistRowsWritten).toBe(1)++ })++++ it('(b) a failed transaction restores dirtiness and the next checkpoint writes it', async () => {++ const store = await openTestStore()++ let failuresLeft = 1++ const { store: flaky } = flakyStore(store, () => failuresLeft-- > 0)++ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)++++ const restored: string[] = []++ let sessionsDirty = 0++ // The snapshot's rows (a real GameWorld clears its dirty flags and put them back in restore; the++ // contract tested here is that the flusher calls restore on failure and retries from a fresh build).++ const build = (): FlushPayload =>++ payload({++ entityUpserts: [entityDto('e1')],++ entityDeletes: ['gone'],++ constraintUpserts: [constraintDto('c1')],++ characters: [character(playerDto('p1', 'Scrapper'))],++ restore: () => {++ restored.push('restore')++ sessionsDirty++++ },++ })++++ flusher.checkpoint(build)++ await flusher.drain()++ expect(restored).toEqual(['restore'])++ expect(sessionsDirty).toBe(1)++ expect(await store.worldEntities.loadAll()).toHaveLength(0)++++ // The next checkpoint carries the restored rows.++ flusher.checkpoint(build)++ await flusher.drain()++ expect((await store.worldEntities.loadAll()).map((e) => e.id)).toEqual(['e1'])++ expect((await store.constraints.loadAll()).map((c) => c.id)).toEqual(['c1'])++ expect((await store.players.findById('p1'))?.name).toBe('Scrapper')++ expect(restored).toEqual(['restore']) // a successful flush does not restore++ })++++ it('(c) coalesces checkpoints: at most one extra transaction', async () => {++ const store = await openTestStore()++ const { store: counted, txCount } = flakyStore(store, () => false)++ const flusher = new Flusher(slowStore(counted, 100), new ServerMetrics(), log, undefined)++ let built = 0++ const build = () => {++ built++++ return payload({ entityUpserts: [entityDto(`e${built}`)] })++ }++ flusher.checkpoint(build)++ flusher.checkpoint(build) // coalesced: build not called, dirty flags stay set++ flusher.checkpoint(build)++ expect(built).toBe(1)++ await flusher.drain()++ expect(txCount()).toBe(1)++++ flusher.checkpoint(build) // queue empty again: one more transaction at most++ await flusher.drain()++ expect(txCount()).toBe(2)++ expect(built).toBe(2)++ })++++ it('(d) a disconnect save queued after a checkpoint of the same character lands last', async () => {++ const store = await openTestStore()++ const flusher = new Flusher(slowStore(store, 50), new ServerMetrics(), log, undefined)++ flusher.checkpoint(() => payload({ characters: [character(playerDto('p1', 'before'))] }))++ flusher.savePlayer(payload({ characters: [character(playerDto('p1', 'after'))] }))++ await flusher.drain()++ expect((await store.players.findById('p1'))?.name).toBe('after')++ })++++ it('(e) drain writes everything a shutdown checkpoint carries', async () => {++ const store = await openTestStore()++ const flusher = new Flusher(store, new ServerMetrics(), log, undefined)++ flusher.checkpoint(() =>++ payload({++ reason: 'shutdown',++ entityUpserts: [entityDto('e1'), entityDto('e2')],++ npcUpserts: [entityDto('npc1')],++ constraintUpserts: [constraintDto('c1')],++ characters: [character(playerDto('p1', 'Scrapper'))],++ }),++ )++ expect(await flusher.drain()).toBe(true)++ expect(await store.worldEntities.loadAll()).toHaveLength(3)++ expect(await store.constraints.loadAll()).toHaveLength(1)++ expect(await store.players.findById('p1')).not.toBeNull()++ })++++ it('(f) outbox rows commit with the world rows and roll back with them', async () => {++ const store = await openTestStore()++ const outbox = createPgOutbox(store.db, { events: fakeEvents() as never })++ const sink = outboxSink(outbox, store.db)++ const recorder = new GameEventRecorder(sink, 'testworld', 0, () => 1)++ const flusher = new Flusher(store, new ServerMetrics(), log, recorder)++ const rows = async (): Promise<number> =>++ Number(await store.db.value(sql`SELECT count(*) FROM event_outbox`))++++ // Rollback: the character row and its games.player.left event commit together or not at all.++ await expect(++ store.transaction(async (t) => {++ await store.players.upsertMany([playerDto('p1', 'Scrapper')])++ await recorder.recordLeave(t, eventPlayerOf('p1', 'Scrapper'))++ throw new Error('boom')++ }),++ ).rejects.toThrow('boom')++ expect(await rows()).toBe(0)++ expect(await store.players.findById('p1')).toBeNull()++++ // Commit: a leaving save writes both.++ flusher.savePlayer(++ payload({ leaving: true, characters: [character(playerDto('p1', 'Scrapper'))] }),++ )++ await flusher.drain()++ expect(await store.players.findById('p1')).not.toBeNull()++ expect(await rows()).toBe(1)++++ // A failed transaction writes neither.++ let fail = true++ const { store: flaky } = flakyStore(store, () => {++ const now = fail++ fail = false++ return now++ })++ const failing = new Flusher(flaky, new ServerMetrics(), log, recorder)++ failing.savePlayer(++ payload({ leaving: true, characters: [character(playerDto('p2', 'Other'))] }),++ )++ await failing.drain()++ expect(await store.players.findById('p2')).toBeNull()++ expect(await rows()).toBe(1)++ })++++ it('(g) guest keys are hashed: a raw guest token never appears in characters', async () => {++ const store = await openTestStore()++ const flusher = new Flusher(store, new ServerMetrics(), log, undefined)++ const raw = 'guestbrowsertoken0123456789abcdef'++ flusher.savePlayer(payload({ characters: [character(playerDto('g1', 'Guest', raw))] }))++ await flusher.drain()++ const row = await store.db.maybe<{ account_key: string }>(++ sql`SELECT account_key FROM characters WHERE id = 'g1'`,++ )++ expect(row?.account_key).toMatch(/^guest:[0-9a-f]{64}$/)++ expect(JSON.stringify(row)).not.toContain(raw)++ expect((await store.players.findByToken(raw))?.id).toBe('g1')++ })++++ it('(h) NPCs write only what changed', async () => {++ const store = await openTestStore()++ const content = createContent()++ const world = { content } as unknown as GameWorld++ const npcs = new NpcManager(++ world,++ new RegionTracker(32),++ {++ onAttackPlayer: () => {},++ soundsThisTick: () => [],++ hostilesNear: () => [],++ },++ log,++ )++ await npcs.seedOrRestore(store)++ const boot = npcs.takeDirty(1000)++ expect(boot.length).toBeGreaterThan(0)++ // Nothing changed since: nothing to write.++ expect(npcs.takeDirty(1000)).toEqual([])++ // One NPC takes a hit: exactly that one is written.++ const victim = boot[0]!.id++ expect(npcs.damage(victim as never, 1, 1000)).toBe('hurt')++ const changed = npcs.takeDirty(1000)++ expect(changed.map((d) => d.id)).toEqual([victim])++ // restoreDirty puts a failed flush's ids back.++ npcs.restoreDirty(changed.map((d) => d.id))++ expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])++ })++})++++describe('GameWorld dirty snapshot', () => {++ it('takeDirty clears flags and restoreDirty puts entities, deletes and constraints back', async () => {++ const store = await openTestStore()++ const world = new GameWorld(createContent(), stubPhysics(), log)++ const a = world.spawnProp({++ defId: 'wooden_crate',++ pos: vec3(1, 1, 1),++ rot: quat(),++ motion: 'frozen',++ })++ const b = world.spawnProp({++ defId: 'metal_barrel',++ pos: vec3(2, 1, 2),++ rot: quat(),++ motion: 'frozen',++ })++ world.addConstraintRecord(a, b, 'weld', {}, 'c1')++++ const first = world.takeDirty()++ expect(first.entityUpserts.map((e) => e.id).sort()).toEqual([a.id, b.id].sort())++ expect(first.constraintUpserts.map((c) => c.id)).toEqual(['c1'])++ expect(world.takeDirty().entityUpserts).toEqual([])++++ world.restoreDirty(first)++ const again = world.takeDirty()++ expect(again.entityUpserts.map((e) => e.id).sort()).toEqual([a.id, b.id].sort())++ expect(again.constraintUpserts.map((c) => c.id)).toEqual(['c1'])++++ // Deletes: a despawned entity is re-armed only while it is really gone.++ world.despawn(a.id)++ const del = world.takeDirty()++ expect(del.entityDeletes).toEqual([a.id])++ world.restoreDirty(del)++ expect(world.takeDirty().entityDeletes).toEqual([a.id])++ await world.flushDirty(store)++ expect(world.takeDirty().entityDeletes).toEqual([])++ })++})nnew file mode 100644@@@ -0,0 +1,242 @@++import type {++ ConstraintDto,++ PersistenceStore,++ PlayerDto,++ WorldEntityDto,++} from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { ServerMetrics } from '../observability/metrics.js'++import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'++import type { PlayerSession } from './playerSession.js'++++/**++ * The write-behind flusher (ADR-0007 decision 5). The tick never awaits I/O: it takes a++ * copy-on-write snapshot of the dirty rows (clearing the flags as it copies) and hands it here, then++ * carries on. One serialized queue per instance runs at most one database transaction at a time.++ *++ * checkpoint(build) called on the tick; if a checkpoint is already queued or running the new one++ * is coalesced — `build` is never called, so the dirty flags stay set and the++ * next checkpoint carries those rows. `{ final: true }` (shutdown) is never++ * coalesced: behind an in-flight checkpoint it is queued and built when it runs++ * (the tick has stopped, so building later sees the same state).++ * savePlayer(payload) a disconnect save, always queued, so it lands after an earlier checkpoint of++ * the same character. A failure is retried in place with backoff (the session is++ * gone, so there is no dirty flag to restore) unless the character came back.++ * drain(timeout) resolves when the queue is empty, or false at the deadline (shutdown, and a++ * returning player's sign-in, so it reads its own last save).++ *++ * A failed transaction restores the snapshot's dirtiness (`payload.restore`): nothing is lost++ * silently, the failure is logged and counted, and the next checkpoint retries.++ */++export interface FlushCharacter {++ session: PlayerSession++ dto: PlayerDto++ player: EventPlayer++ progress: ProgressSnapshot++}++++export interface FlushPayload {++ reason: 'checkpoint' | 'shutdown'++ /** A disconnect save also records games.player.left in the same transaction. */++ leaving: boolean++ /** Whether a games.world.saved checkpoint event belongs to this flush. */++ worldSaved: boolean++ meta: Record<string, unknown>++ entityUpserts: readonly WorldEntityDto[]++ entityDeletes: readonly string[]++ npcUpserts: readonly WorldEntityDto[]++ constraintUpserts: readonly ConstraintDto[]++ constraintDeletes: readonly string[]++ characters: readonly FlushCharacter[]++ worldSavedCount: number++ /** Puts the snapshot's dirtiness back after a failed transaction. */++ restore: () => void++}++++export class Flusher {++ private chain: Promise<void> = Promise.resolve()++ private checkpointActive = false++ private failures = 0++ private coalesced = 0++ private pendingJobs = 0++++ constructor(++ private readonly store: PersistenceStore,++ private readonly metrics: ServerMetrics,++ private readonly log: Logger,++ private readonly recorder: GameEventRecorder | undefined,++ ) {}++++ /** Queued or running jobs. */++ get pending(): number {++ return this.pendingJobs++ }++++ /**++ * Synchronous entry point for the tick. Returns immediately; when a checkpoint is already in++ * flight this one is coalesced (the builder is not called, so nothing is cleared).++ */++ checkpoint(build: () => FlushPayload | null, { final = false }: { final?: boolean } = {}): void {++ if (this.checkpointActive && final) {++ void this.enqueue(async () => {++ const payload = build()++ if (!payload) return++ try {++ await this.run(payload)++ } catch (err) {++ this.countFailure(err)++ payload.restore()++ }++ })++ return++ }++ if (this.checkpointActive) {++ this.coalesced++++ this.metrics.persistCoalesced = this.coalesced++ return++ }++ const payload = build()++ if (!payload) return++ this.checkpointActive = true++ void this.enqueue(async () => {++ try {++ await this.run(payload)++ } catch (err) {++ this.countFailure(err)++ payload.restore()++ } finally {++ this.checkpointActive = false++ }++ })++ }++++ /**++ * A character save queued behind any in-flight checkpoint; never coalesced. `returned()` says++ * whether the character is online again (its session then carries the state, and a retry would++ * write an older copy over it).++ */++ savePlayer(++ payload: FlushPayload,++ returned: () => boolean = () => false,++ retryDelaysMs = [1000, 2000, 4000, 8000],++ ): void {++ void this.enqueue(async () => {++ for (let attempt = 0; ; attempt++) {++ try {++ await this.run(payload)++ return++ } catch (err) {++ this.countFailure(err)++ if (returned() || attempt >= retryDelaysMs.length) {++ payload.restore()++ if (!returned())++ this.log.error(++ 'a character save failed for good: its changes since the last checkpoint are lost',++ {++ characters: payload.characters.map((c) => c.dto.id).join(','),++ },++ )++ return++ }++ await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]))++ }++ }++ })++ }++++ /** Resolves true when the queue is empty, false when it is not empty by `timeoutMs`. */++ async drain(timeoutMs = 25_000): Promise<boolean> {++ const deadline = Date.now() + timeoutMs++ for (;;) {++ const current = this.chain++ const left = deadline - Date.now()++ if (left <= 0) return false++ let timer: ReturnType<typeof setTimeout> | undefined++ const timedOut = await Promise.race([++ current.then(() => false),++ new Promise<boolean>((r) => {++ timer = setTimeout(() => r(true), left)++ }),++ ])++ clearTimeout(timer)++ if (timedOut) return false++ if (current === this.chain) return true++ }++ }++++ private enqueue(job: () => Promise<void>): Promise<void> {++ this.pendingJobs++++ this.metrics.dbDirtyQueue = this.pendingJobs++ const next = this.chain.then(job)++ // The chain never rejects: a failed job is handled by its own catch, and the next job must run.++ this.chain = next.then(++ () => {},++ () => {},++ )++ return next.finally(() => {++ this.pendingJobs--++ this.metrics.dbDirtyQueue = this.pendingJobs++ })++ }++++ private async run(payload: FlushPayload): Promise<void> {++ const t0 = performance.now()++ const afterCommit: (() => void)[] = []++ await this.store.transaction(async (t) => {++ for (const [key, value] of Object.entries(payload.meta)) {++ await this.store.meta.set(key, value)++ }++ if (payload.npcUpserts.length > 0)++ await this.store.worldEntities.upsertMany(payload.npcUpserts)++ if (payload.entityUpserts.length > 0)++ await this.store.worldEntities.upsertMany(payload.entityUpserts)++ if (payload.entityDeletes.length > 0)++ await this.store.worldEntities.deleteMany(payload.entityDeletes)++ if (payload.constraintUpserts.length > 0)++ await this.store.constraints.upsertMany(payload.constraintUpserts)++ if (payload.constraintDeletes.length > 0)++ await this.store.constraints.deleteMany(payload.constraintDeletes)++ if (payload.characters.length > 0)++ await this.store.players.upsertMany(payload.characters.map((c) => c.dto))++ if (this.recorder) {++ afterCommit.push(++ await this.recorder.recordPlayersSaved(++ t,++ payload.characters.map((c) => ({ player: c.player, progress: c.progress })),++ ),++ )++ if (payload.leaving) {++ for (const c of payload.characters)++ afterCommit.push(await this.recorder.recordLeave(t, c.player))++ }++ if (payload.worldSaved) {++ afterCommit.push(++ await this.recorder.recordWorldSaved(++ t,++ payload.worldSavedCount,++ payload.characters.length,++ payload.reason,++ ),++ )++ }++ }++ })++ for (const done of afterCommit) done()++ this.metrics.persistRowsWritten =++ payload.npcUpserts.length +++ payload.entityUpserts.length +++ payload.entityDeletes.length +++ payload.constraintUpserts.length +++ payload.constraintDeletes.length +++ payload.characters.length++ this.metrics.persistFlushMs = performance.now() - t0++ }++++ private countFailure(err: unknown): void {++ this.failures++++ this.metrics.persistFlushFailures = this.failures++ this.log.error('persistence flush failed: rows stay dirty and will be retried', {++ error: String((err as Error | undefined)?.message ?? err),++ })++ }++}@@@ -98,6 +98,7 @@ import {import { buildSnapshot, updateInterest, wireEntityFor } from './replication.js'import { NpcManager } from './npcManager.js'import { EventManager } from './eventManager.js'++import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'/** A network connection as the game sees it — transport-agnostic. */export interface GameConnection {@@@ -107,6 +108,8 @@ export interface GameConnection {const MOVE = DEFAULT_MOVEMENTconst MAX_INPUT_QUEUE = 6++/** The five weather kinds a persisted env_weather may name. */++const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])/*** Platform adapters the server drives (roadmap Wave 12). Both optional: the@@@ -141,6 +144,12 @@ export class GameServer {private readonly heldEntityIds = new Set<string>()/** Short-lived cache of OFFLINE owners' friend lists (prop protection). */private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()++ /** Offline-owner lookups in flight, so the tick never issues the same read twice. */++ private readonly friendsLoadInFlight = new Set<string>()++ /** Connections whose hello is still resolving (a second one while it is refused). */++ private readonly hellosInFlight = new Set<GameConnection>()++ /** Write-behind persistence: one transaction in flight, the tick never awaits it. */++ private readonly flusher: Flusherprivate tick = 0private readonly moveQueries: CollisionQueries/** Body excluded from the current movement sweep (the moving player's own). */@@@ -172,20 +181,9 @@ export class GameServer {private readonly log: Logger,private readonly integrations: GameIntegrations = {},) {-- // The world clock and weather survive restarts (a rainy dusk stays a-- // rainy dusk).-- const savedTime = Number(store.meta.get('env_time') ?? Number.NaN)-- this.env = createEnvironment(Number.isFinite(savedTime) ? savedTime : 0.34)-- const savedWeather = store.meta.get('env_weather')-- if (-- savedWeather === 'clear' ||-- savedWeather === 'cloudy' ||-- savedWeather === 'rain' ||-- savedWeather === 'storm' ||-- savedWeather === 'fog'-- ) {-- this.env.weather = savedWeather as WeatherKind-- }++ // The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are++ // loaded asynchronously in load(), before the tick loop starts.++ this.env = createEnvironment(0.34)this.npcs = new NpcManager(world,this.regions,@@@ -222,7 +220,6 @@ export class GameServer {},log.child({ system: 'npc' }),)-- this.npcs.seedOrRestore(store)this.events = new EventManager(world,@@@ -249,6 +246,13 @@ export class GameServer {log.child({ system: 'events' }),)++ this.flusher = new Flusher(++ store,++ metrics,++ log.child({ system: 'persist' }),++ integrations.events,++ )++// Players block players: sweeps include the Player layer, minus the// mover's own kinematic body.this.moveQueries = {@@@ -264,6 +268,32 @@ export class GameServer {}}++ /**++ * Async boot: world clock/weather, market stock and the NPC population are read from PostgreSQL.++ * main.ts awaits this before starting the tick loop.++ */++ async load(): Promise<void> {++ const rawTime = await this.store.meta.get('env_time')++ const savedTime = Number(rawTime ?? Number.NaN)++ if (Number.isFinite(savedTime)) this.env.timeOfDay = savedTime++ const savedWeather = await this.store.meta.get('env_weather')++ if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {++ this.env.weather = savedWeather as WeatherKind++ }++ for (const { key, value } of await this.store.meta.list('market_')) {++ const market = new Map<string, { stock: number; at: number }>()++ if (value && typeof value === 'object') {++ for (const [item, state] of Object.entries(++ value as Record<string, { stock: number; at: number }>,++ )) {++ market.set(item, state)++ }++ }++ this.marketStock.set(key.slice('market_'.length), market)++ }++ await this.npcs.seedOrRestore(this.store)++ }++get currentTick(): number {return this.tick}@@@ -282,10 +312,25 @@ export class GameServer {if (cached && Date.now() - cached.at < 30_000) {return cached.friends.has(session.playerId)}-- const owner = this.store.players.findById(entity.owner)-- const friends = new Set(owner?.friends ?? [])-- this.offlineFriendsCache.set(entity.owner, { friends, at: Date.now() })-- return friends.has(session.playerId)++ // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits++ // I/O; the cached answer is used from the next tick on (the same 30 s TTL).++ const owner = entity.owner++ if (!this.friendsLoadInFlight.has(owner)) {++ this.friendsLoadInFlight.add(owner)++ void this.store.players++ .findById(owner)++ .then((found) => {++ this.offlineFriendsCache.set(owner, {++ friends: new Set(found?.friends ?? []),++ at: Date.now(),++ })++ })++ .catch((err: unknown) => {++ this.log.warn('offline owner lookup failed', { owner, error: String(err) })++ })++ .finally(() => this.friendsLoadInFlight.delete(owner))++ }++ return false}// ── Connection lifecycle ───────────────────────────────────────────@@@ -293,8 +338,15 @@ export class GameServer {onMessage(conn: GameConnection, msg: ClientMessage): void {const session = this.sessionsByConn.get(conn)if (!session) {-- if (msg.t === 'hello') void this.handleHello(conn, msg)-- else conn.close(4001, 'hello_first')++ if (msg.t === 'hello') {++ // A second hello while the first is still resolving (it awaits Network) is refused, so two++ // frames cannot both create a session for one connection.++ if (this.hellosInFlight.has(conn)) {++ conn.close(4008, 'hello_in_flight')++ return++ }++ void this.handleHello(conn, msg)++ } else conn.close(4001, 'hello_first')return}switch (msg.t) {@@@ -461,7 +513,7 @@ export class GameServer {break}case 'trust': {-- this.handleTrust(session, msg.player, msg.trusted)++ void this.handleTrust(session, msg.player, msg.trusted)break}case 'constraint': {@@@ -778,17 +830,8 @@ export class GameServer {): { stock: number; at: number } {let market = this.marketStock.get(marketId)if (!market) {-- // Restore from the persisted blob once per market.++ // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.market = new Map()-- const raw = this.store.meta.get(`market_${marketId}`)-- if (raw) {-- try {-- const parsed = JSON.parse(raw) as Record<string, { stock: number; at: number }>-- for (const [item, s] of Object.entries(parsed)) market.set(item, s)-- } catch {-- /* corrupted blob: fall through to fresh stock */-- }-- }this.marketStock.set(marketId, market)}let state = market.get(entry.item)@@@ -803,12 +846,6 @@ export class GameServer {return state}-- private persistMarkets(): void {-- for (const [id, market] of this.marketStock) {-- this.store.meta.set(`market_${id}`, JSON.stringify(Object.fromEntries(market)))-- }-- }--/*** Market interactions: open/buy/sell against the market a shop prop* references. Everything server-atomic: proximity, faction stance,@@@ -1218,7 +1255,17 @@ export class GameServer {this.log.info('player disconnected', { playerId: session.playerId, name: session.name })}++ /** Guards one hello per connection while it resolves (it awaits Network); see onMessage. */private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {++ this.hellosInFlight.add(conn)++ try {++ await this.resolveHello(conn, msg)++ } finally {++ this.hellosInFlight.delete(conn)++ }++ }++++ private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {if (msg.v !== PROTOCOL_VERSION) {conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))conn.close(4002, 'protocol_mismatch')@@@ -1254,7 +1301,7 @@ export class GameServer {// Guest conversion (WS-B task 8): the character this browser played as a guest joins the// account in its first free slot, once.if (isGuestToken(msg.token)) {-- const g = this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())++ const g = await this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())if (g.moved > 0)this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })else if (g.full)@@@ -1275,7 +1322,9 @@ export class GameServer {return}}-- const existing = this.store.players.findByTokenSlot(token, slot)++ // A disconnect save of this character may still be queued: read after it lands, not before.++ await this.flusher.drain(5000)++ const existing = await this.store.players.findByTokenSlot(token, slot)// One live session per CHARACTER; other characters of the same account// may stay online (an account still only plays one at a time in// practice — same token kicks apply per slot).@@@ -1378,7 +1427,7 @@ export class GameServer {})this.sendInventory(session)this.sendSkills(session)-- this.sendFriends(session)++ await this.sendFriends(session)this.send(session, this.timeWire())this.sendReputation(session)this.metrics.sessions = this.sessions.size@@@ -1390,9 +1439,9 @@ export class GameServer {const stored = existing? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks): { levels: {}, unlocks: [] }-- this.store.transaction(() =>-- recorder.recordJoin(eventPlayer(session), stored, existing !== null),-- )++ await this.store.transaction(async (t) => {++ await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)++ })}this.log.info('player connected', {playerId: playerId as string,@@@ -1485,14 +1534,18 @@ export class GameServer {}}-- private handleTrust(session: PlayerSession, targetId: string, trusted: boolean): void {++ private async handleTrust(++ session: PlayerSession,++ targetId: string,++ trusted: boolean,++ ): Promise<void> {if (targetId === (session.playerId as string)) {this.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })return}// Target must be a real player (online or persisted).const online = this.sessions.get(targetId as PlayerId)-- const known = online ?? this.store.players.findById(targetId)++ const known = online ?? (await this.store.players.findById(targetId))if (!known) {this.send(session, { t: 'result', action: 'trust', ok: false, error: 'unknown_player' })return@@@ -1501,14 +1554,14 @@ export class GameServer {else session.friends.delete(targetId)session.dirty = truethis.send(session, { t: 'result', action: 'trust', ok: true })-- this.sendFriends(session)++ await this.sendFriends(session)}-- private sendFriends(session: PlayerSession): void {++ private async sendFriends(session: PlayerSession): Promise<void> {const friends: { id: string; name: string }[] = []for (const id of session.friends) {const online = this.sessions.get(id as PlayerId)-- const name = online?.name ?? this.store.players.findById(id)?.name ?? 'unknown'++ const name = online?.name ?? (await this.store.players.findById(id))?.name ?? 'unknown'friends.push({ id, name })}this.send(session, { t: 'friends', friends })@@@ -2340,10 +2393,10 @@ export class GameServer {// retracted on the first tick after the change).this.integrations.mods?.tick(this.tick, this.modHost)-- // 9. Periodic persistence flush.++ // 9. Periodic persistence checkpoint (write-behind: returns immediately).if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {this.lastFlushTick = this.tick-- this.flush()++ this.checkpoint()}this.metrics.tick = this.tick@@@ -2484,62 +2537,113 @@ export class GameServer {// ── Persistence ────────────────────────────────────────────────────/**-- * One transaction per flush: world rows, player rows and the outbox-- * events describing them commit together (or not at all).++ * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind++ * flusher. It returns immediately; a checkpoint already in flight coalesces this one.*/-- flush(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {-- const recorder = this.integrations.events-- const dirty: PlayerSession[] = []-- const afterCommit = this.store.transaction(() => {-- // World clock + weather ride along with every flush (tiny meta writes).-- this.store.meta.set('env_time', String(this.env.timeOfDay))-- this.store.meta.set('env_weather', this.env.weather)-- this.persistMarkets()-- this.npcs.flush(this.store, Date.now())-- const wrote = this.world.flushDirty(this.store)-- const dirtyPlayers: PlayerDto[] = []-- for (const session of this.sessions.values()) {-- if (!session.dirty) continue-- dirtyPlayers.push(this.playerToDto(session))-- dirty.push(session)-- }-- if (dirtyPlayers.length > 0) this.store.players.upsertMany(dirtyPlayers)-- if (wrote > 0 || dirtyPlayers.length > 0) {-- this.log.debug('persistence flush', { entities: wrote, players: dirtyPlayers.length })-- }-- if (!recorder) return []-- return [-- recorder.recordPlayersSaved(-- dirty.map((sess) => ({ player: eventPlayer(sess), progress: sessionProgress(sess) })),-- ),-- recorder.recordWorldSaved(wrote, dirtyPlayers.length, reason),-- ]-- })-- for (const session of dirty) session.dirty = false-- for (const done of afterCommit) done()-- this.metrics.dbDirtyQueue = 0++ private checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {++ this.flusher.checkpoint(() => this.buildFlushPayload(reason))++ }++++ /**++ * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction++ * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events++ * describing them; on failure the snapshot's dirtiness is restored.++ */++ private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {++ const now = Date.now()++ const world = this.world.takeDirty()++ const npcs = this.npcs.takeDirty(now)++ const characters: FlushCharacter[] = []++ for (const session of this.sessions.values()) {++ if (!session.dirty) continue++ session.dirty = false++ characters.push(this.flushCharacter(session))++ }++ // World clock + weather and the market blobs ride along with every checkpoint.++ const meta: Record<string, unknown> = {++ env_time: this.env.timeOfDay,++ env_weather: this.env.weather,++ }++ for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)++ if (world.entityUpserts.length > 0 || characters.length > 0) {++ this.log.debug('persistence checkpoint', {++ entities: world.entityUpserts.length,++ npcs: npcs.length,++ players: characters.length,++ })++ }++ return {++ reason,++ leaving: false,++ worldSaved: true,++ meta,++ entityUpserts: world.entityUpserts,++ entityDeletes: world.entityDeletes,++ npcUpserts: npcs,++ constraintUpserts: world.constraintUpserts,++ constraintDeletes: world.constraintDeletes,++ characters,++ worldSavedCount: world.entityUpserts.length,++ restore: () => {++ this.world.restoreDirty(world)++ this.npcs.restoreDirty(npcs.map((n) => n.id))++ // A disconnected session was saved by its own queued save, which runs after this one.++ for (const c of characters) {++ if (this.sessions.has(c.session.playerId)) c.session.dirty = true++ }++ },++ }++ }++++ private flushCharacter(session: PlayerSession): FlushCharacter {++ return {++ session,++ dto: this.playerToDto(session),++ player: eventPlayer(session),++ progress: sessionProgress(session),++ }}-- /** Full save on shutdown. */++ /** Full save on shutdown; main.ts awaits drain() after this. */shutdown(): void {for (const session of this.sessions.values()) session.dirty = true-- this.flush('shutdown')++ // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.++ this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })this.log.info('world saved on shutdown', {})}-- /** Saves one character; `leaving` also records games.player.left in the same transaction. */++ /** Resolves when the write-behind queue is empty (shutdown). */++ async drain(timeoutMs = 25_000): Promise<boolean> {++ return this.flusher.drain(timeoutMs)++ }++++ /**++ * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is++ * built synchronously and the transaction is queued on the same queue as checkpoints, so a++ * disconnect save always lands after an earlier checkpoint of that character. The caller does not++ * await it.++ */private savePlayer(session: PlayerSession, leaving = false): void {-- const recorder = this.integrations.events-- const afterCommit = this.store.transaction(() => {-- this.store.players.upsert(this.playerToDto(session))-- if (!recorder) return []-- const player = eventPlayer(session)-- const done = [recorder.recordPlayersSaved([{ player, progress: sessionProgress(session) }])]-- if (leaving) done.push(recorder.recordLeave(player))-- return done-- })session.dirty = false-- for (const done of afterCommit) done()++ this.flusher.savePlayer(++ {++ reason: 'checkpoint',++ leaving,++ worldSaved: false,++ meta: {},++ entityUpserts: [],++ entityDeletes: [],++ npcUpserts: [],++ constraintUpserts: [],++ constraintDeletes: [],++ characters: [this.flushCharacter(session)],++ worldSavedCount: 0,++ restore: () => {++ if (this.sessions.has(session.playerId)) session.dirty = true++ },++ },++ () => this.sessions.has(session.playerId),++ )}/**@@@ -576,22 +576,22 @@ export class GameWorld {// ── Persistence mapping ────────────────────────────────────────────-- seedOrRestore(store: PersistenceStore): void {++ async seedOrRestore(store: PersistenceStore): Promise<void> {const world = this.content.world-- const seeded = store.meta.get('world_seeded') === 'yes'-- const worldChanged = store.meta.get('world_id') !== world.id++ const seeded = (await store.meta.get('world_seeded')) === true++ const worldChanged = (await store.meta.get('world_id')) !== world.idif (!seeded) {this.seedProps(store)this.seedResources(store)-- store.meta.set('world_seeded', 'yes')-- store.meta.set('world_id', world.id)-- this.flushDirty(store)++ await store.meta.set('world_seeded', true)++ await store.meta.set('world_id', world.id)++ await this.flushDirty(store)this.log.info('world seeded', { world: world.id, entities: this.entities.size })return}-- const rows = store.worldEntities.loadAll()++ const rows = await store.worldEntities.loadAll()let restored = 0for (const row of rows) {if (row.kind === 'resource' && worldChanged) continue // re-seeded below@@@ -600,7 +600,7 @@ export class GameWorld {// Constraints restore after entities; dangling/invalid records are// pruned (an unknown type from a future build, or a despawned prop).-- const constraintRows = store.constraints.loadAll()++ const constraintRows = await store.constraints.loadAll()const pruned: string[] = []for (const row of constraintRows) {const a = this.entities.get(row.entityA as EntityId)@@@ -619,16 +619,16 @@ export class GameWorld {pruned.push(row.id)}}-- if (pruned.length > 0) store.constraints.deleteMany(pruned)++ if (pruned.length > 0) await store.constraints.deleteMany(pruned)if (worldChanged) {// The map definition changed: resource layout follows the new world,// player constructions persist, players go back to spawn.-- store.worldEntities.deleteByKind('resource')++ await store.worldEntities.deleteByKind('resource')this.seedResources(store)-- store.players.resetAllPositions(worldSpawn(world).pos, worldSpawn(world).yaw)-- store.meta.set('world_id', world.id)-- this.flushDirty(store)++ await store.players.resetAllPositions(worldSpawn(world).pos, worldSpawn(world).yaw)++ await store.meta.set('world_id', world.id)++ await this.flushDirty(store)this.log.info('world definition changed — resources re-seeded, players respawned', {world: world.id,})@@@ -910,46 +910,93 @@ export class GameWorld {return false}-- flushDirty(store: PersistenceStore): number {-- const dirty: WorldEntityDto[] = []++ /**++ * Copy-on-write snapshot of everything dirty, clearing the flags and sets as it copies. Taken++ * synchronously on the tick; the write-behind flusher persists it in one transaction. A failed++ * flush calls `restoreDirty` so nothing is lost.++ */++ takeDirty(): WorldDirtySnapshot {const now = Date.now()++ const entityUpserts: WorldEntityDto[] = []for (const entity of this.entities.all()) {// Players persist via their repository; NPCs via the NpcManager// (their abstract state, never the materialized entity).if (!entity.dirty || !entity.persistent) continueif (entity.kind === 'player' || entity.kind === 'npc') continue-- dirty.push(entityToDto(entity, now))++ entityUpserts.push(entityToDto(entity, now))entity.dirty = false}-- if (dirty.length > 0) store.worldEntities.upsertMany(dirty)-- if (this.deletedIds.size > 0) {-- store.worldEntities.deleteMany([...this.deletedIds])-- this.deletedIds.clear()-- }-- if (this.constraintsDirty.size > 0) {-- const dtos: ConstraintDto[] = []-- for (const id of this.constraintsDirty) {-- const rec = this.constraintRecords.get(id)-- if (rec) {-- dtos.push({-- id: rec.id,-- type: rec.type,-- entityA: rec.a,-- entityB: rec.b,-- params: rec.params as Record<string, unknown>,-- updatedAt: now,-- })-- }++ const entityDeletes = [...this.deletedIds]++ this.deletedIds.clear()++++ const constraintUpserts: ConstraintDto[] = []++ for (const id of this.constraintsDirty) {++ const rec = this.constraintRecords.get(id)++ if (rec) {++ constraintUpserts.push({++ id: rec.id,++ type: rec.type,++ entityA: rec.a,++ entityB: rec.b,++ params: rec.params as Record<string, unknown>,++ updatedAt: now,++ })}-- store.constraints.upsertMany(dtos)-- this.constraintsDirty.clear()}-- if (this.constraintsDeleted.size > 0) {-- store.constraints.deleteMany([...this.constraintsDeleted])-- this.constraintsDeleted.clear()++ this.constraintsDirty.clear()++ const constraintDeletes = [...this.constraintsDeleted]++ this.constraintsDeleted.clear()++++ return {++ entityUpserts,++ entityDeletes,++ constraintUpserts,++ constraintDeletes,++ dirtyEntityIds: entityUpserts.map((e) => e.id),++ dirtyConstraintIds: constraintUpserts.map((c) => c.id),++ }++ }++++ /** Puts back what a failed flush took: rows still present are dirty again, deletes re-armed. */++ restoreDirty(snapshot: WorldDirtySnapshot): void {++ for (const id of snapshot.dirtyEntityIds) {++ const entity = this.entities.get(id as EntityId)++ if (entity) entity.dirty = true++ }++ for (const id of snapshot.entityDeletes) {++ if (!this.entities.get(id as EntityId)) this.deletedIds.add(id as EntityId)++ }++ for (const id of snapshot.dirtyConstraintIds) {++ if (this.constraintRecords.has(id)) this.constraintsDirty.add(id)++ }++ for (const id of snapshot.constraintDeletes) {++ if (!this.constraintRecords.has(id)) this.constraintsDeleted.add(id)}-- return dirty.length}++++ /** Boot/seed convenience: take everything dirty and write it in one call (never on the tick). */++ async flushDirty(store: PersistenceStore): Promise<number> {++ const snapshot = this.takeDirty()++ if (snapshot.entityUpserts.length > 0)++ await store.worldEntities.upsertMany(snapshot.entityUpserts)++ if (snapshot.entityDeletes.length > 0)++ await store.worldEntities.deleteMany(snapshot.entityDeletes)++ if (snapshot.constraintUpserts.length > 0)++ await store.constraints.upsertMany(snapshot.constraintUpserts)++ if (snapshot.constraintDeletes.length > 0)++ await store.constraints.deleteMany(snapshot.constraintDeletes)++ return snapshot.entityUpserts.length++ }++}++++/** What `takeDirty` hands the flusher; `restoreDirty` can put it all back on failure. */++export interface WorldDirtySnapshot {++ entityUpserts: WorldEntityDto[]++ entityDeletes: string[]++ constraintUpserts: ConstraintDto[]++ constraintDeletes: string[]++ dirtyEntityIds: string[]++ dirtyConstraintIds: string[]}function entityToDto(entity: GameEntity, now: number): WorldEntityDto {@@@ -51,6 +51,8 @@ export class NpcManager {private readonly states = new Map<string, NpcState>()/** Materialized subset: instance id -> physics body. */private readonly bodies = new Map<string, BodyId>()++ /** Instances whose persisted abstract state changed since the last flush. */++ private readonly dirtyIds = new Set<string>()private readonly navigation: NavigationService = new StraightLineNavigation()private behaviorPhase = 0@@@ -62,9 +64,10 @@ export class NpcManager {) {}/** Boot: restore persisted NPCs, then seed content spawns not yet known. */-- seedOrRestore(store: PersistenceStore): void {-- const rows = store.worldEntities.loadAll().filter((r) => r.kind === 'npc')++ async seedOrRestore(store: PersistenceStore): Promise<void> {++ const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')for (const row of rows) {++ this.dirtyIds.add(row.id)const arch = this.world.content.npc(row.defId)if (!arch) continueconst s = row.state ?? {}@@@ -108,6 +111,7 @@ export class NpcManager {lastAttackMs: 0,respawnAt: 0,})++ this.dirtyIds.add(id)}this.log.info('npcs ready', { count: this.states.size })}@@@ -130,6 +134,7 @@ export class NpcManager {const arch = npc ? this.world.content.npc(npc.archetype) : undefinedif (!npc || !arch || npc.respawnAt > 0) return nullnpc.health -= amount++ this.dirtyIds.add(npc.id)if (npc.health > 0) return 'hurt'npc.respawnAt = nowMs + arch.respawnSeconds * 1000npc.health = 0@@@ -151,6 +156,7 @@ export class NpcManager {npc.x = npc.homeXnpc.z = npc.homeZnpc.state = 'idle'++ this.dirtyIds.add(npc.id)}const wantFull = npc.respawnAt === 0 && this.regions.isActive(npc.x, npc.z)if (wantFull && !materialized) this.materialize(npc)@@@ -230,6 +236,7 @@ export class NpcManager {npc.x += (dx / dist) * stepnpc.z += (dz / dist) * stepnpc.yaw = Math.atan2(dx, dz)++ this.dirtyIds.add(npc.id)}}// Sync entity + body + spatial from the authoritative state.@@@ -299,10 +306,16 @@ export class NpcManager {return hit === null}-- /** Persistence: NPCs write their ABSTRACT state (never bodies/entities). */-- flush(store: PersistenceStore, now: number): void {++ /**++ * Copy-on-write snapshot of the NPCs whose abstract state changed, clearing the dirty set. Taken++ * synchronously on the tick; only changed NPC rows are written, not the whole population.++ */++ takeDirty(now: number): WorldEntityDto[] {++ if (this.dirtyIds.size === 0) return []const dtos: WorldEntityDto[] = []-- for (const npc of this.states.values()) {++ for (const id of this.dirtyIds) {++ const npc = this.states.get(id)++ if (!npc) continuedtos.push({id: npc.id,kind: 'npc',@@@ -320,6 +333,12 @@ export class NpcManager {updatedAt: now,})}-- if (dtos.length > 0) store.worldEntities.upsertMany(dtos)++ this.dirtyIds.clear()++ return dtos++ }++++ /** A failed flush: mark these NPCs dirty again (only if they still exist). */++ restoreDirty(ids: readonly string[]): void {++ for (const id of ids) if (this.states.has(id)) this.dirtyIds.add(id)}}@@@ -1,7 +1,7 @@/*** The Wave 12 platform boundary exercised on the real authoritative server-- * (headless Havok, real SQLite file, protocol-level connections), across a-- * restart:++ * (headless Havok, real PostgreSQL schema through in-memory PGlite,++ * protocol-level connections), across a restart:** - a signed-in player is keyed by the canonical subject;* - a guest presenting an account key as its token is refused;@@@ -17,7 +17,8 @@ import { mkdtempSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'import { createContent } from '@openvibe/content'--import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'++import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'import { createHeadlessHavokWorld } from '@openvibe/physics/havok'import {PROTOCOL_VERSION,@@@ -26,12 +27,8 @@ import {type ServerMessage,} from '@openvibe/protocol'import { createConsoleLogger } from '@openvibe/shared'--import {-- createEventsClient,-- createOutbox,-- type Outbox,-- type SqliteDatabase,--} from 'openvibe-sdk/events'++import type { Db } from 'openvibe-sdk/db'++import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'import { createMockPlatform } from 'openvibe-sdk/testing'import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'import { loadConfig } from '../config.js'@@@ -53,16 +50,17 @@ const STAFF: ModActor = { audit: SUBJECT, subject: { type: 'user', id: SUBJECT }const log = createConsoleLogger({ app: 'test' }, 'error')const dir = mkdtempSync(join(tmpdir(), 'openvibe-platform-'))--const dbPath = join(dir, 'world.db')const config = loadConfig({-- DB_PATH: dbPath,MAP_PATH: join(dir, 'map.json'),OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',})let havok: unknown++/** One in-memory PGlite database for the whole file; the restart tests share its state. */++let db: DbbeforeAll(async () => {havok = await loadHavok()++ db = await openTestDb()// openvibe.network's /api/auth/me for the one signed-in test account.vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {const auth = new Headers(init?.headers).get('authorization')@@@ -76,20 +74,22 @@ afterAll(() => {})interface Boot {-- store: SqlitePersistenceStore++ store: PgPersistenceStoreworld: GameWorldgame: GameServerregistry: ModRegistry-- outbox: Outbox++ outbox: PgOutboxdispose(): void}--function boot(platform: ReturnType<typeof createMockPlatform>): Boot {++async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {const content = createContent()const physics = createHeadlessHavokWorld(havok)-- const store = openSqliteStore(dbPath)++ // The store rides on the shared database; close() is never called (the instance is shared).++ const store = openPgStore(db, { placeId: config.placeId })++ await store.ensurePlace()const world = new GameWorld(content, physics, log)-- world.seedOrRestore(store)++ await world.seedOrRestore(store)const pc = createPlatformClient({...config.platform,@@@ -99,18 +99,21 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {},platform.fetch,)!-- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {++ const outbox = createPgOutbox(db, {events: createEventsClient(pc.client, { source: 'games' }),})-- outbox.ensureSchema()-- const sink = outboxSink(outbox)-- const registry = new ModRegistry(store, content, sink)-- const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 30 })++ const sink = outboxSink(outbox, db)++ const registry = new ModRegistry(store, content, sink, Date.now, log.child({ system: 'mods' }))++ await registry.load()++ const runtime = new ModRuntime(registry, content, log.child({ system: 'mods' }), {++ reconcileEveryTicks: 30,++ })const recorder = new GameEventRecorder(sink, content.world.id, 0)const game = new GameServer(config, world, store, new ServerMetrics(), log, {events: recorder,mods: runtime,})++ await game.load()return {store,world,@@@ -118,7 +121,6 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {registry,outbox,dispose() {-- store.close()physics.dispose()},}@@@ -165,10 +167,10 @@ describe('Games on platform identity, events and mods (real server, restart)', (},})let signedInPlayerId = ''-- let timeBefore = ''++ let timeBefore = 0it('runs identity, mods and events against the live world', async () => {-- const a = boot(platform)++ const a = await boot(platform)// A guest cannot claim an account key as its token.const intruder = connect(a.game)@@@ -181,14 +183,16 @@ describe('Games on platform identity, events and mods (real server, restart)', (const joined = await ana.hello('abcdefgh12', 'tok-ana')expect(joined.welcome).toBeDefined()signedInPlayerId = (joined.welcome as { playerId: string }).playerId-- a.game.flush()-- expect(a.store.players.findById(signedInPlayerId)).toMatchObject({++ // The tick never awaits I/O; shutdown queues the final checkpoint, drain waits for it.++ a.game.shutdown()++ await a.game.drain()++ expect(await a.store.players.findById(signedInPlayerId)).toMatchObject({token: SUBJECT,subjectId: SUBJECT,})// Mod A: props granted, announcements requested but denied.-- a.registry.install(++ await a.registry.install({manifest: sampleManifest({ id: MOD_A }),pack: {@@@ -201,7 +205,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (STAFF,)// Mod B: stays installed across the restart.-- a.registry.install(++ await a.registry.install({manifest: sampleManifest({ id: MOD_B, name: 'Campfire' }),pack: { props: [{ key: 'fire', item: 'campfire', pos: [-20, 0, -20] }] },@@@ -215,20 +219,25 @@ describe('Games on platform identity, events and mods (real server, restart)', (expect(benches.map((e) => e.prop?.defId)).toEqual(['workbench'])expect(propsOwnedBy(a.world, MOD_B).map((e) => e.prop?.defId)).toEqual(['campfire'])expect(ana.inbox.some((m) => m.t === 'announce')).toBe(false)++ await a.registry.flushWrites()expect(-- a.registry.auditLog(MOD_A).some((x) => x.action === 'deny' && x.capability === CAP_ANNOUNCE),++ (await a.registry.auditLog(MOD_A)).some(++ (x) => x.action === 'deny' && x.capability === CAP_ANNOUNCE,++ ),).toBe(true)// Revoke: gone on the very next tick, and players are told it despawned.-- a.registry.revoke(MOD_A, STAFF, 'test')++ await a.registry.revoke(MOD_A, STAFF, 'test')a.game.step()expect(propsOwnedBy(a.world, MOD_A)).toEqual([])expect(a.world.entities.get(benches[0]!.id)).toBeUndefined()a.game.onDisconnect(ana.conn)a.game.shutdown()-- timeBefore = a.store.meta.get('env_time') ?? ''-- expect(timeBefore).not.toBe('')++ await a.game.drain()++ await a.registry.flushWrites()++ timeBefore = (await a.store.meta.get('env_time')) as number++ expect(timeBefore).toBeGreaterThan(0)// Everything committed is published with the games principal.await a.outbox.flush()@@@ -252,9 +261,9 @@ describe('Games on platform identity, events and mods (real server, restart)', (}, 60_000)it('restores the authoritative state after a restart', async () => {-- const b = boot(platform)++ const b = await boot(platform)// World clock, mod registry and mod props came back from disk.-- expect(b.store.meta.get('env_time')).toBe(timeBefore)++ expect(await b.store.meta.get('env_time')).toBe(timeBefore)expect(b.registry.get(MOD_A)?.mod.status).toBe('revoked')expect(b.registry.get(MOD_B)?.mod.status).toBe('enabled')expect(propsOwnedBy(b.world, MOD_A)).toEqual([])@@@ -267,35 +276,43 @@ describe('Games on platform identity, events and mods (real server, restart)', (const ana = connect(b.game)const again = await ana.hello('zyxwvut98', 'tok-ana')expect(again.welcome).toMatchObject({ playerId: signedInPlayerId })-- expect(b.store.players.listByToken(SUBJECT).map((p) => p.id)).toEqual([signedInPlayerId])++ expect((await b.store.players.listByToken(SUBJECT)).map((p) => p.id)).toEqual([++ signedInPlayerId,++ ])b.game.onDisconnect(ana.conn)b.game.shutdown()++ await b.game.drain()++ await b.registry.flushWrites()await b.outbox.stop()b.dispose()}, 60_000)it('a guest who signs in keeps their character (guest conversion, WS-B task 8)', async () => {-- const c = boot(platform)++ const c = await boot(platform)const guest = connect(c.game)const played = await guest.hello('guestconv77')expect(played.welcome).toBeDefined()const guestPlayer = (played.welcome as { playerId: string }).playerIdc.game.shutdown()++ await c.game.drain()++ await c.registry.flushWrites()await c.outbox.stop()c.dispose()-- const d = boot(platform)++ const d = await boot(platform)const signedIn = connect(d.game)expect((await signedIn.hello('guestconv77', 'tok-ana')).welcome).toBeDefined()-- const mine = d.store.players.listByToken(SUBJECT)++ const mine = await d.store.players.listByToken(SUBJECT)expect(mine.some((p) => p.id === guestPlayer)).toBe(true)-- expect(d.store.players.listByToken('guestconv77')).toEqual([])++ expect(await d.store.players.listByToken('guestconv77')).toEqual([])d.game.shutdown()++ await d.game.drain()++ await d.registry.flushWrites()await d.outbox.stop()d.dispose()}, 60_000)it('a sign-out everywhere closes the signed-in session, never a guest (WS-B task 4)', async () => {-- const c = boot(platform)++ const c = await boot(platform)const ana = connect(c.game)expect((await ana.hello('abcdefgh12', 'tok-ana')).welcome).toBeDefined()const guest = connect(c.game)@@@ -305,6 +322,8 @@ describe('Games on platform identity, events and mods (real server, restart)', (expect(ana.inbox.some((m) => m.t === 'reject' && m.reason === 'signed_out')).toBe(true)expect(guest.inbox.some((m) => m.t === 'reject')).toBe(false)c.game.shutdown()++ await c.game.drain()++ await c.registry.flushWrites()await c.outbox.stop()c.dispose()}, 60_000)@@@ -1,16 +1,13 @@--import { mkdirSync, existsSync, readFileSync } from 'node:fs'++import { existsSync, readFileSync } from 'node:fs'import { dirname, join } from 'node:path'--import {-- createEventsClient,-- createOutbox,-- type Outbox,-- type SqliteDatabase,--} from 'openvibe-sdk/events'++import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'++import { createValkey } from 'openvibe-sdk/valkey'import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { openPgStore } from '@openvibe/persistence'import { createHeadlessHavokWorld } from '@openvibe/physics/havok'import { createConsoleLogger } from '@openvibe/shared'import { loadConfig } from './config.js'++import { openDb } from './db.js'import { GameServer } from './game/gameServer.js'import { GameWorld } from './game/gameWorld.js'import { loadHavok } from './havokLoader.js'@@@ -55,7 +52,7 @@ import { createStaffAuthorizer } from './platform/staffAuth.js'/*** Dedicated authoritative server entry point.-- * Boot order: config -> content validation (fail fast) -> physics ->++ * Boot order: config -> content validation (fail fast) -> physics -> database + migrations ->* persistence -> world restore -> network -> fixed-tick loop.*/async function main(): Promise<void> {@@@ -64,7 +61,7 @@ async function main(): Promise<void> {process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',)const config = loadConfig(process.env)-- log.info('starting', { port: config.port, db: config.dbPath })++ log.info('starting', { port: config.port, place: config.placeId })// Content validates at construction — invalid definitions kill the boot.const content = createContent()@@@ -102,24 +99,26 @@ async function main(): Promise<void> {const havok = await loadHavok()const physics = createHeadlessHavokWorld(havok)-- mkdirSync(dirname(config.dbPath), { recursive: true })-- const store = openSqliteStore(config.dbPath)++ // PostgreSQL (or embedded PGlite in development), migrated before anything writes.++ const db = await openDb(config.db, log)++ const store = openPgStore(db, { placeId: config.placeId })++ await store.ensurePlace()const metrics = new ServerMetrics()const world = new GameWorld(content, physics, log.child({ system: 'world' }))-- world.seedOrRestore(store)++ await world.seedOrRestore(store)// ── Platform integration (roadmap Wave 12) ─────────────────────────// Every service call carries the `games` principal's client-credentials// token; with no client secret configured none of this talks to anything.const platform = createPlatformClient(config.platform)const platformLog = log.child({ system: 'platform' })-- let outbox: Outbox | null = null++ const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })++ let outbox: PgOutbox | null = nulllet sink: EventSink = NO_EVENTSif (platform && config.platform.eventsUrl) {let lastError: string | null = null-- // better-sqlite3's Transaction<F> is a callable F; the SDK types it as plain F.-- outbox = createOutbox(store.db as unknown as SqliteDatabase, {++ outbox = createPgOutbox(db, {events: createEventsClient(platform.client, { source: EVENT_SOURCE }),intervalMs: 2000,onError: (err) => {@@@ -129,16 +128,19 @@ async function main(): Promise<void> {lastError = message},})-- outbox.ensureSchema()-- sink = outboxSink(outbox)++ sink = outboxSink(outbox, db)outbox.start()-- platformLog.info('events on', { url: config.platform.eventsUrl, pending: outbox.pending() })++ platformLog.info('events on', {++ url: config.platform.eventsUrl,++ pending: await outbox.pending(),++ })}const recorder = sink.enabled? new GameEventRecorder(sink, content.world.id, config.platform.worldSavedEventMinutes * 60_000): undefined-- const mods = new ModRegistry(store, content, sink)-- const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {++ const mods = new ModRegistry(store, content, sink, Date.now, log.child({ system: 'mods' }))++ await mods.load()++ const modRuntime = new ModRuntime(mods, content, log.child({ system: 'mods' }), {reconcileEveryTicks: config.tickRate,})// Mod principals live in Network (ADR-013, WS-M task 3): the API asks it first; at boot every install's copy@@@ -147,23 +149,21 @@ async function main(): Promise<void> {? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }): nullif (modGrants) {-- modGrants-- .list()-- .then((principals) => {-- for (const p of principals) mods.applyNetwork(p)-- const known = new Set(principals.map((p) => p.mod_id))-- const unregistered = mods-- .list()-- .filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked')-- .map((v) => v.mod.id)-- if (unregistered.length)-- log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })++ try {++ const principals = await modGrants.list()++ for (const p of principals) await mods.applyNetwork(p)++ const known = new Set(principals.map((p) => p.mod_id))++ const unregistered = mods++ .list()++ .filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked')++ .map((v) => v.mod.id)++ if (unregistered.length)++ log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })++ } catch (err) {++ log.warn('mod principals not read at boot', {++ error: String((err as Error)?.message ?? err),})-- .catch((err: unknown) =>-- log.warn('mod principals not read at boot', {-- error: String((err as Error)?.message ?? err),-- }),-- )++ }}const mirror =platform && config.platform.mediaUrl@@@ -186,7 +186,7 @@ async function main(): Promise<void> {networkUrl: config.platform.networkUrl,editorKey: config.editorKey,})-- const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)++ const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)pruneTimer.unref()// games.progress.summary on Network (WS-B task 9): needs the games principal.@@@ -198,16 +198,22 @@ async function main(): Promise<void> {mods: modRuntime,...(progressSummary ? { progressSummary } : {}),})++ // Async boot: env, markets and the NPC population load before the tick loop starts.++ await game.load()const accountData = platform? createAccountData({-- db: store.db,++ db,identity: store.identity,send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),closeSessions: async (subjects) => {let closed = 0for (const s of subjects) closed += game.revokeSubject(s, Number.MAX_SAFE_INTEGER)-- if (closed) await new Promise((r) => setTimeout(r, 2000))++ // Let the closing sockets' character saves land before the rows are erased.++ if (closed) {++ await new Promise((r) => setTimeout(r, 2000))++ await game.drain()++ }},log: platformLog.child({ system: 'account-data' }),})@@@ -216,7 +222,7 @@ async function main(): Promise<void> {// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's// older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).const revocations = createRevocationEvents({-- db: store.db,++ db,secrets: config.platform.eventsSecrets,onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.@@@ -225,12 +231,13 @@ async function main(): Promise<void> {// close saves its character) before its characters are erased....(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),// Mod principals (ADR-013): an install's grants follow its principal in Network (a change staff made there).-- onModGrants: (p) => {-- const view = mods.applyNetwork(p)++ onModGrants: async (p) => {++ const view = await mods.applyNetwork(p)return view ? `mod:${view.mod.status}` : 'ignored:unknown_mod'},log: platformLog,})++ await revocations.store.load()if (platform &&config.platform.eventsUrl &&@@@ -260,17 +267,18 @@ async function main(): Promise<void> {// GET /shared/*: this server's own pinned OpenVibe Frame files (D42), not openvibe.network's.const serveShared = sharedAssetsHandler()-- // GET /api/ready: world.db answers and the simulation ticks (/healthz stays liveness only).-- const schemaVersion = store.db.prepare('SELECT value FROM meta WHERE key = ?')++ // GET /api/ready: the database answers (db.ready()) and the simulation ticks (/healthz stays liveness only).const readiness = createReadiness({-- pingDb: () => schemaVersion.get('schema_version') !== undefined,++ pingDb: () => db.ready(),metrics,online: () => game.onlineCount(),})-- // Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4).++ // Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4). With VALKEY_URL the++ // counters are shared across instances; otherwise they live in this process.const actorLimits = createGamesActorLimits({onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),++ valkey,})const http = createHttpServer(config.staticDir,@@@ -310,13 +318,12 @@ async function main(): Promise<void> {// Guest conversion (WS-B task 8): this browser's guest character shows up in the account's// list (and moves there) the moment it signs in, before any slot is picked.if (isGuestToken(token))-- store.identity.adoptGuestCharacter(token, user.subjectId, Date.now())++ await store.identity.adoptGuestCharacter(token, user.subjectId, Date.now())} else if (!isGuestToken(token)) {// Never list an account key's characters for a guest query.return []}-- return store.players-- .listByToken(account)++ return (await store.players.listByToken(account)).slice(0, 3).map((p) => ({ slot: p.charSlot, name: p.name, appearance: p.appearance }))},@@@ -328,27 +335,52 @@ async function main(): Promise<void> {if (serveRelease(req, res)) return trueif (serveShared(req, res)) return trueif ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {-- // Operational status of the platform adapters; never secrets.-- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })-- res.end(-- JSON.stringify({-- principal: platform ? platform.clientId : null,-- events: outbox-- ? { enabled: true, pending: outbox.pending(), rejected: outbox.rejected() }-- : { enabled: false },-- media: mirror-- ? {-- enabled: true,-- namespace: config.platform.mediaNamespace,-- ...store.mediaMirrors.counts(),-- }-- : { enabled: false },-- mods: {-- installed: mods.list().length,-- active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,-- },-- }),-- )++ // Operational status of the platform adapters; never secrets. The DB reads are async; the++ // response is written when they resolve. A database error answers 503 rather than leaving the++ // request hanging or rejecting unhandled.++ void (async () => {++ const mediaCounts = mirror ? await store.mediaMirrors.counts() : null++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })++ res.end(++ JSON.stringify({++ principal: platform ? platform.clientId : null,++ events: outbox++ ? {++ enabled: true,++ pending: await outbox.pending(),++ rejected: await outbox.rejected(),++ }++ : { enabled: false },++ media: mediaCounts++ ? {++ enabled: true,++ namespace: config.platform.mediaNamespace,++ ...mediaCounts,++ }++ : { enabled: false },++ mods: {++ installed: mods.list().length,++ active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,++ },++ }),++ )++ })().catch((err: unknown) => {++ log.warn('platform status failed', {++ error: String((err as Error | undefined)?.message ?? err),++ })++ if (res.writableEnded) return++ res.writeHead(503, {++ 'content-type': 'application/problem+json; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end(++ JSON.stringify({++ title: 'service_unavailable',++ status: 503,++ detail: 'platform status unavailable',++ }),++ )++ })return true}return handleModsRequest(req, res, {@@@ -413,10 +445,11 @@ async function main(): Promise<void> {// systemd sends SIGTERM (TimeoutStopSec=30). In order: stop taking connections (HTTP and WebSocket// upgrades) while requests in flight carry on; stop the simulation, the metrics and prune timers and// the media mirror; tell every player, then close every game and editor socket with 1012 (each game-- // close saves that character and records games.player.left; the clients reconnect on their own);-- // save the world; let requests in flight finish (DRAIN_MS); let the mirror pass and the progress-- // summaries settle; stop the event outbox and await its last send (unsent rows stay in the table-- // for the next start); close world.db; exit 0. Everything within DEADLINE_MS, else exit 1.++ // close saves that character and records games.player.left); a final checkpoint with every session++ // dirty; await the write-behind drain (the checkpoint and every queued save); let requests in flight++ // finish (DRAIN_MS); let the mirror pass and the progress summaries settle; stop the event outbox and++ // await its last send (unsent rows stay for the next start); close the database; exit 0. Everything++ // within DEADLINE_MS, else exit 1.let stopping: Promise<void> | null = nullconst shutdown = (signal: string): Promise<void> => {if (stopping) return stopping@@@ -439,13 +472,18 @@ async function main(): Promise<void> {closeSockets(gameWss, SOCKETS_MS),closeSockets(editors.wss, SOCKETS_MS),])++ // Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue++ // (that checkpoint plus every disconnect save queued before it) drain.game.shutdown()++ const drained = await game.drain()const cut = await httpDoneawait within(2000, mirrorDone)await within(1000, progressSummary?.settle())await within(2000, outbox?.stop())-- store.close()++ await mods.flushWrites()++ await store.close()physics.dispose()++ await valkey?.close()log.info('stopped', {ms: Date.now() - t0,players: players.closed + players.terminated,@@@ -453,6 +491,7 @@ async function main(): Promise<void> {terminated: players.terminated + editorPeers.terminated,requestsCut: cut,outbox: outbox ? 'stopped' : 'off',++ drained,})process.exit(0)})().catch((err: unknown) => {@@@ -6,9 +6,10 @@*/import { createRequire } from 'node:module'import { createContent } from '@openvibe/content'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { openTestStore } from '@openvibe/persistence/testing'import { createClient } from 'openvibe-sdk/core'--import { createEventsClient, createOutbox, type SqliteDatabase } from 'openvibe-sdk/events'++import { sql } from 'openvibe-sdk/db'++import { createEventsClient, createPgOutbox } from 'openvibe-sdk/events'import { describe, expect, it } from 'vitest'import { EVENT_SOURCE, outboxSink } from '../platform/gameEvents.js'import { CAP_PLACE_PROP } from './contentPack.js'@@@ -43,8 +44,8 @@ interface Envelope {}}--function setup() {-- const store = openSqliteStore(':memory:')++async function setup() {++ const store = await openTestStore()const client = createClient({baseUrls: { events: 'http://127.0.0.1:9' },autoDiscover: false,@@@ -53,22 +54,22 @@ function setup() {throw new Error('no relay in this test')},})-- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {++ const outbox = createPgOutbox(store.db, {events: createEventsClient(client, { source: EVENT_SOURCE }),})-- outbox.ensureSchema()-- const registry = new ModRegistry(store, createContent(), outboxSink(outbox))-- registry.install(++ await outbox.ensureSchema()++ const registry = new ModRegistry(store, createContent(), outboxSink(outbox, store.db))++ await registry.install({ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },STAFF,)-- const moderation = () =>++ const moderation = async (): Promise<Envelope[]> =>(-- store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all() as {-- envelope: string-- }[]++ await store.db.many<{ envelope: Envelope }>(++ sql`SELECT envelope FROM event_outbox ORDER BY id`,++ ))-- .map((r) => JSON.parse(r.envelope) as Envelope)++ .map((r) => r.envelope).filter((e) => e.event_type === 'games.moderation.action')return { registry, outbox, moderation }}@@@ -85,10 +86,10 @@ function expectValid(e: Envelope) {}describe('games.moderation.action', () => {-- it("staff revoking someone else's mod enqueues exactly one valid event", () => {-- const { registry, outbox, moderation } = setup()-- registry.revoke(MOD_ID, STAFF, 'griefing props')-- const ev = moderation()++ it("staff revoking someone else's mod enqueues exactly one valid event", async () => {++ const { registry, outbox, moderation } = await setup()++ await registry.revoke(MOD_ID, STAFF, 'griefing props')++ const ev = await moderation()expect(ev).toHaveLength(1)expectValid(ev[0]!)expect(ev[0]!.payload.action).toBe('mod.revoked')@@@ -97,29 +98,29 @@ describe('games.moderation.action', () => {previous: 'enabled',revoked_grants: [CAP_PLACE_PROP],})-- registry.revoke(MOD_ID, STAFF, 'again')-- expect(moderation()).toHaveLength(1)++ await registry.revoke(MOD_ID, STAFF, 'again')++ expect(await moderation()).toHaveLength(1)void outbox.stop()})-- it('a disable, and the enable that puts it back, are one event each; installing is none', () => {-- const { registry, outbox, moderation } = setup()-- expect(moderation()).toHaveLength(0)-- registry.disable(MOD_ID, STAFF)-- expect(moderation().map((e) => e.payload.action)).toEqual(['mod.disabled'])-- registry.enable(MOD_ID, STAFF)-- const ev = moderation()++ it('a disable, and the enable that puts it back, are one event each; installing is none', async () => {++ const { registry, outbox, moderation } = await setup()++ expect(await moderation()).toHaveLength(0)++ await registry.disable(MOD_ID, STAFF)++ expect((await moderation()).map((e) => e.payload.action)).toEqual(['mod.disabled'])++ await registry.enable(MOD_ID, STAFF)++ const ev = await moderation()expect(ev.map((e) => e.payload.action)).toEqual(['mod.disabled', 'mod.enabled'])for (const e of ev) expectValid(e)void outbox.stop()})-- it('the publisher acting on their own mod enqueues none', () => {-- const { registry, outbox, moderation } = setup()-- registry.disable(MOD_ID, OWNER)-- registry.enable(MOD_ID, OWNER)-- registry.revoke(MOD_ID, OWNER, 'withdrawn by its author')-- expect(moderation()).toHaveLength(0)++ it('the publisher acting on their own mod enqueues none', async () => {++ const { registry, outbox, moderation } = await setup()++ await registry.disable(MOD_ID, OWNER)++ await registry.enable(MOD_ID, OWNER)++ await registry.revoke(MOD_ID, OWNER, 'withdrawn by its author')++ expect(await moderation()).toHaveLength(0)void outbox.stop()})})@@@ -1,7 +1,8 @@import { createServer, type Server } from 'node:http'import type { AddressInfo } from 'node:net'import { createContent } from '@openvibe/content'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import type { PgPersistenceStore } from '@openvibe/persistence'++import { openTestStore } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'import { afterEach, describe, expect, it } from 'vitest'import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'@@@ -87,7 +88,8 @@ function fakeNetwork(allow: string[]) {}async function start(grants: ModGrantAuthority) {-- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())++ const registry = new ModRegistry(await openTestStore(), createContent())++ await registry.load()server = createServer((req, res) => {if (!handleModsRequest(req, res, { registry, authorize: async () => STAFF, grants, log })) {res.writeHead(404)@@@ -133,22 +135,22 @@ describe('mod principals in Network', () => {expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])// Network's events arrive after the API applied its answers: an older revision changes nothing.expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: MOD_ID,status: 'active',approved: [CAP_PLACE_PROP],revision: 1,})-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: MOD_ID,status: 'active',approved: [CAP_PLACE_PROP],revision: 3,})expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)-- expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(false)++ expect((await registry.auditLog(MOD_ID)).some((a) => a.actor === 'network')).toBe(false)// A newer one (a staff change in Network) applies.-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: MOD_ID,status: 'active',approved: [CAP_PLACE_PROP],@@@ -180,30 +182,65 @@ describe('mod principals in Network', () => {expect(registry.get(MOD_ID)).toBeNull()})-- it("applyNetwork makes an install's copy follow its principal", () => {-- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())-- registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)++ it("applyNetwork makes an install's copy follow its principal", async () => {++ const registry = new ModRegistry(await openTestStore(), createContent())++ await registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)expect(-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ',status: 'active',approved: [],}),).toBeNull()-- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })++ await registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: MOD_ID,status: 'active',approved: [CAP_ANNOUNCE, 'games.not.bindable'],})expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])-- expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(true)-- registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })++ expect((await registry.auditLog(MOD_ID)).some((a) => a.actor === 'network')).toBe(true)++ await registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })expect(registry.get(MOD_ID)?.mod.status).toBe('revoked')expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)})++ it('does not advance the revision when its writes fail: the same revision applies on redelivery', async () => {++ const store = await openTestStore()++ let fail = true++ const wrapped = {++ ...store,++ mods: {++ ...store.mods,++ upsertGrant: async (grant: Parameters<typeof store.mods.upsertGrant>[0]) => {++ if (fail) throw new Error('db down')++ await store.mods.upsertGrant(grant)++ },++ },++ } as PgPersistenceStore++ const registry = new ModRegistry(wrapped, createContent())++ await registry.install({ manifest, pack, approve: [], enable: true }, STAFF)++ // The write fails: the revision must not be remembered, or Events' redelivery would be dropped as stale.++ await expect(++ registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_ANNOUNCE],++ revision: 7,++ }),++ ).rejects.toThrow('db down')++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)++ fail = false++ await registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_ANNOUNCE],++ revision: 7,++ })++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)++ })++it('the client sends the principal token and maps Network answers', async () => {const seen: { url: string; auth: string | null; body: unknown }[] = []const answers: Response[] = [@@@ -1,7 +1,7 @@/*** The mod registry (ADR-013, roadmap Wave 12): installs, the approved subset* of each install's requested capabilities, lifecycle and the audit log, all-- * in Games' own SQLite database.++ * in Games' own PostgreSQL database.** install validate the manifest + pack, store it, grant the approved subset* enable / disable@@@ -9,12 +9,14 @@* revoke terminal: every grant ends and the runtime retracts the mod's* effects on the next tick*-- * Every change is one transaction holding the registry rows, the audit rows-- * and the `games.mod.*` outbox event. The in-memory view the runtime asks on-- * its hot path (`isGranted`) is refreshed only after that commit. Taking down-- * (revoke, disable) or putting back (enable after a disable) a mod someone-- * else published also writes `games.moderation.action` for Network's-- * moderation audit log (ADR-022) in that transaction.++ * Every change is one database transaction holding the registry rows, the audit rows and the++ * `games.mod.*` outbox event. The in-memory view the runtime asks on its hot path (`isGranted`, and the++ * placements mirror) is refreshed only after that commit.++ *++ * The tick never awaits I/O: `audit`, `place` and `unplace` are called from the mod runtime on the tick++ * and go through a serialized write queue (the placements mirror updates immediately, the database write++ * commits asynchronously); a failure is logged and counted, never thrown into the tick. Everything the++ * HTTP API and Network events call is explicit and awaited.** Trust tiers are metadata: `isGranted` never looks at them.*@@@ -29,11 +31,13 @@ import type {ModAuditDto,ModGrantDto,ModInstallDto,++ ModPlacementDto,ModStatus,ModTrustTier,PersistenceStore,} from '@openvibe/persistence'import type { SubjectRef } from 'openvibe-sdk/core'++import type { Tx } from 'openvibe-sdk/db'import {moderationEvent,modEvent,@@@ -96,17 +100,32 @@ export interface ModView {export class ModRegistry {private readonly views = new Map<string, ModView>()++ /** In-memory mirror of each install's placements the runtime reads on the tick. */++ private readonly placementsByMod = new Map<string, ModPlacementDto[]>()/** The Network principal revision each install's grants were last set from (an older event changes nothing). */private readonly networkRevision = new Map<string, number>()++ /** Serialized writes the tick path (and only it) fires without awaiting. */++ private writeChain: Promise<void> = Promise.resolve()private revision = 0++ /** Writes the tick path queued that failed; the runtime keeps running. */++ writeFailures = 0constructor(private readonly store: PersistenceStore,private readonly content: ContentRegistry,private readonly events: EventSink = NO_EVENTS,private readonly now: () => number = Date.now,-- ) {-- for (const mod of store.mods.list()) this.refresh(mod.id)++ private readonly log: { warn(msg: string, meta?: unknown): void } = {++ warn: (msg, meta) => console.warn(msg, meta),++ },++ ) {}++++ /** Async boot: read every install, its grants and its placements into the in-memory mirrors. */++ async load(): Promise<void> {++ for (const mod of await this.store.mods.list()) await this.refresh(mod.id)++ for (const view of this.views.values()) {++ this.placementsByMod.set(view.mod.id, await this.store.mods.placements(view.mod.id))++ }}/** Bumps on every committed change; the runtime reconciles when it moves. */@@@ -126,6 +145,11 @@ export class ModRegistry {return this.views.get(id)?.mod.status === 'enabled'}++ /** The in-memory placements mirror (the tick reads this, never the database). */++ placements(modId: string): ModPlacementDto[] {++ return this.placementsByMod.get(modId) ?? []++ }++/*** The one grant check. True only while the install is enabled and the* capability is in its approved, unrevoked subset. The trust tier is not@@@ -136,30 +160,116 @@ export class ModRegistry {return view !== undefined && view.mod.status === 'enabled' && view.granted.has(capability)}-- auditLog(id: string, limit = 100): ModAuditDto[] {++ auditLog(id: string, limit = 100): Promise<ModAuditDto[]> {return this.store.mods.auditLog(id, Math.min(Math.max(limit, 1), 500))}-- /** Appends a runtime audit record (use, deny, retract, …). */-- audit(id: string, action: string, capability: string | null, detail?: Record<string, unknown>) {-- this.store.mods.audit({-- modId: id,-- action,-- capability,-- actor: SYSTEM_ACTOR.audit,-- detail: detail ?? null,-- at: this.now(),++ /**++ * Appends a runtime audit record (use, deny, retract, …). Called from the tick: the write is queued,++ * not awaited, and the placements/audit queue is one at a time so ordering is kept.++ */++ audit(++ id: string,++ action: string,++ capability: string | null,++ detail?: Record<string, unknown>,++ ): void {++ this.enqueueWrite(async () => {++ await this.store.mods.audit({++ modId: id,++ action,++ capability,++ actor: SYSTEM_ACTOR.audit,++ detail: detail ?? null,++ at: this.now(),++ })++ })++ }++++ /** Records (or re-records) a placement in the mirror and queue-writes it. Called from the tick. */++ place(modId: string, key: string, entityId: string | null): void {++ const at = this.now()++ const before = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key === key)++ const list = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key !== key)++ list.push({ modId, key, entityId, at })++ this.placementsByMod.set(modId, list)++ this.enqueueWrite(++ async () => {++ await this.store.mods.setPlacement({ modId, key, entityId, at })++ },++ () => this.rollbackPlacement(modId, key, at, before),++ )++ }++++ /** Removes a placement from the mirror and queue-writes the delete. Called from the tick. */++ unplace(modId: string, key: string): void {++ const before = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key === key)++ const list = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key !== key)++ this.placementsByMod.set(modId, list)++ this.enqueueWrite(++ async () => {++ await this.store.mods.deletePlacement(modId, key)++ },++ () => this.rollbackPlacement(modId, key, null, before),++ )++ }++++ /**++ * The queued write never landed: put the mirror back so it agrees with the database. Only undoes the++ * change when the mirror still holds it (`stamp` is what this call set, or null for a removal), so a++ * later place/unplace of the same key is left to its own write.++ */++ private rollbackPlacement(++ modId: string,++ key: string,++ stamp: number | null,++ before: ModPlacementDto[],++ ): void {++ const current = this.placementsByMod.get(modId) ?? []++ const entry = current.find((p) => p.key === key)++ if ((entry?.at ?? null) !== stamp) return++ const rest = current.filter((p) => p.key !== key)++ this.placementsByMod.set(modId, [...rest, ...before])++ this.log.warn('mod placement write failed: mirror rolled back', { mod: modId, key })++ }++++ /** One serialized write queue for the tick path; failures are retried, then logged and counted, never thrown. */++ private enqueueWrite(write: () => Promise<void>, onGiveUp?: () => void): void {++ this.writeChain = this.writeChain.then(async () => {++ const delaysMs = [25, 100]++ for (let attempt = 0; ; attempt++) {++ try {++ await write()++ return++ } catch (err: unknown) {++ if (attempt < delaysMs.length) {++ await new Promise((r) => setTimeout(r, delaysMs[attempt]))++ continue++ }++ this.writeFailures++++ onGiveUp?.()++ this.log.warn('mod write failed', {++ error: String((err as Error | undefined)?.message ?? err),++ })++ return++ }++ }})}++ /** Waits for queued tick-path writes (shutdown/tests). */++ async flushWrites(): Promise<void> {++ await this.writeChain++ }++/** Everything install checks before it writes: the manifest, the pack, the approved subset and the options. */-- checkInstall(req: InstallRequest): {++ async checkInstall(req: InstallRequest): Promise<{manifest: ModManifestpack: ContentPackapprove: string[]trustTier: ModTrustTierenable: boolean-- } {++ }> {const manifestCheck = validateManifest(req.manifest)if (!manifestCheck.ok) {throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)@@@ -188,14 +298,14 @@ export class ModRegistry {for (const cap of approve) this.assertGrantable(manifest, cap)const trustTier = parseTrustTier(req.trustTier ?? 'unreviewed')const enable = req.enable === true-- if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {++ if (this.views.has(manifest.id) || (await this.store.mods.get(manifest.id))) {throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)}return { manifest, pack, approve, trustTier, enable }}-- install(req: InstallRequest, actor: ModActor): ModView {-- const { manifest, pack, approve, trustTier, enable } = this.checkInstall(req)++ async install(req: InstallRequest, actor: ModActor): Promise<ModView> {++ const { manifest, pack, approve, trustTier, enable } = await this.checkInstall(req)const requested = new Set(manifest.permissions.capabilities)const at = this.now()const mod: ModInstallDto = {@@@ -212,20 +322,20 @@ export class ModRegistry {installedAt: at,updatedAt: at,}-- this.store.transaction(() => {-- this.store.mods.insert(mod)-- this.writeAudit(mod.id, 'install', null, actor, {++ await this.store.transaction(async (t) => {++ await this.store.mods.insert(mod)++ await this.writeAudit(mod.id, 'install', null, actor, {version: mod.version,runtime: mod.runtime,trust_tier: trustTier,requested: [...requested].sort(),})for (const cap of approve) {-- this.store.mods.upsertGrant(grantRow(mod.id, cap, actor, at))-- this.writeAudit(mod.id, 'grant', cap, actor, null)++ await this.store.mods.upsertGrant(grantRow(mod.id, cap, actor, at))++ await this.writeAudit(mod.id, 'grant', cap, actor, null)}-- if (enable) this.writeAudit(mod.id, 'enable', null, actor, null)-- this.emit('installed', mod, actor, {++ if (enable) await this.writeAudit(mod.id, 'enable', null, actor, null)++ await this.emit(t, 'installed', mod, actor, {requested: [...requested].sort(),granted: [...approve].sort(),status: mod.status,@@@ -234,31 +344,31 @@ export class ModRegistry {return this.refresh(mod.id)}-- enable(id: string, actor: ModActor): ModView {++ enable(id: string, actor: ModActor): Promise<ModView> {return this.setStatus(id, 'enabled', actor)}-- disable(id: string, actor: ModActor): ModView {++ disable(id: string, actor: ModActor): Promise<ModView> {return this.setStatus(id, 'disabled', actor)}/** Ends the install for good: every grant is revoked and the mod stops at the next tick. */-- revoke(id: string, actor: ModActor, reason?: string): ModView {++ async revoke(id: string, actor: ModActor, reason?: string): Promise<ModView> {const view = this.require(id)if (view.mod.status === 'revoked') return viewconst at = this.now()-- this.store.transaction(() => {++ await this.store.transaction(async (t) => {for (const cap of view.granted) {-- this.store.mods.revokeGrant(id, cap, actor.audit, at)-- this.writeAudit(id, 'revoke_grant', cap, actor, null)++ await this.store.mods.revokeGrant(id, cap, actor.audit, at)++ await this.writeAudit(id, 'revoke_grant', cap, actor, null)}-- this.store.mods.setStatus(id, 'revoked', at)-- this.writeAudit(id, 'revoke', null, actor, reason ? { reason } : null)-- this.emit('revoked', { ...view.mod, status: 'revoked' }, actor, {++ await this.store.mods.setStatus(id, 'revoked', at)++ await this.writeAudit(id, 'revoke', null, actor, reason ? { reason } : null)++ await this.emit(t, 'revoked', { ...view.mod, status: 'revoked' }, actor, {revoked_grants: [...view.granted].sort(),...(reason ? { reason } : {}),})-- this.moderated('mod.revoked', view, actor, reason, {++ await this.moderated(t, 'mod.revoked', view, actor, reason, {previous: view.mod.status,revoked_grants: [...view.granted].sort(),})@@@ -266,7 +376,7 @@ export class ModRegistry {return this.refresh(id)}-- grant(id: string, capability: string, actor: ModActor): ModView {++ async grant(id: string, capability: string, actor: ModActor): Promise<ModView> {const view = this.require(id)if (view.mod.status === 'revoked') {throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)@@@ -274,23 +384,23 @@ export class ModRegistry {this.assertGrantable(view.manifest, capability)if (view.granted.has(capability)) return viewconst at = this.now()-- this.store.transaction(() => {-- this.store.mods.upsertGrant(grantRow(id, capability, actor, at))-- this.writeAudit(id, 'grant', capability, actor, null)-- this.emit('grants_changed', view.mod, actor, { granted: [capability], revoked: [] })++ await this.store.transaction(async (t) => {++ await this.store.mods.upsertGrant(grantRow(id, capability, actor, at))++ await this.writeAudit(id, 'grant', capability, actor, null)++ await this.emit(t, 'grants_changed', view.mod, actor, { granted: [capability], revoked: [] })})return this.refresh(id)}-- revokeGrant(id: string, capability: string, actor: ModActor): ModView {++ async revokeGrant(id: string, capability: string, actor: ModActor): Promise<ModView> {const view = this.require(id)const at = this.now()let changed = false-- this.store.transaction(() => {-- changed = this.store.mods.revokeGrant(id, capability, actor.audit, at)++ await this.store.transaction(async (t) => {++ changed = await this.store.mods.revokeGrant(id, capability, actor.audit, at)if (!changed) return-- this.writeAudit(id, 'revoke_grant', capability, actor, null)-- this.emit('grants_changed', view.mod, actor, { granted: [], revoked: [capability] })++ await this.writeAudit(id, 'revoke_grant', capability, actor, null)++ await this.emit(t, 'grants_changed', view.mod, actor, { granted: [], revoked: [capability] })})return changed ? this.refresh(id) : view}@@@ -301,7 +411,7 @@ export class ModRegistry {}/** What grant checks before it writes (the Network call comes between). */-- checkGrant(id: string, capability: string): void {++ async checkGrant(id: string, capability: string): Promise<void> {const view = this.require(id)if (view.mod.status === 'revoked') {throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)@@@ -314,24 +424,32 @@ export class ModRegistry {* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.*/-- applyNetwork(++ async applyNetwork(principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number },actor: ModActor = NETWORK_ACTOR,-- ): ModView | null {++ ): Promise<ModView | null> {const view = this.views.get(principal.mod_id)if (!view) return null-- // Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.-- if (principal.revision !== undefined) {-- if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return view-- this.networkRevision.set(view.mod.id, principal.revision)++ // Events can arrive after the API already applied a newer answer: an older (or equal) revision changes++ // nothing. The revision is remembered only once the writes below succeed, so a failed write leaves the++ // answer eligible for redelivery instead of being dropped as stale.++ if (++ principal.revision !== undefined &&++ principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)++ )++ return view++ const revision = principal.revision++ const applied = (outcome: ModView): ModView => {++ if (revision !== undefined) this.noteNetworkRevision(view.mod.id, revision)++ return outcome}if (principal.status === 'revoked')-- return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')-- if (view.mod.status === 'revoked') return view++ return applied(await this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network'))++ if (view.mod.status === 'revoked') return applied(view)const approved = new Set(principal.approved)let current = viewfor (const cap of [...current.granted])-- if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)++ if (!approved.has(cap)) current = await this.revokeGrant(view.mod.id, cap, actor)for (const cap of approved) {if (current.granted.has(cap)) continuetry {@@@ -339,29 +457,33 @@ export class ModRegistry {} catch {continue}-- current = this.grant(view.mod.id, cap, actor)++ current = await this.grant(view.mod.id, cap, actor)}-- return current++ return applied(current)}-- private setStatus(id: string, status: ModStatus, actor: ModActor): ModView {++ private async setStatus(id: string, status: ModStatus, actor: ModActor): Promise<ModView> {const view = this.require(id)if (view.mod.status === 'revoked') {throw new ModError('mod.revoked', 'a revoked install stays revoked; install a new one', 409)}if (view.mod.status === status) return viewconst at = this.now()-- this.store.transaction(() => {++ await this.store.transaction(async (t) => {// Enabling puts a mod back only when someone disabled it before (a first enable is not moderation).const restoring =status === 'enabled' &&-- this.store.mods.auditLog(id, 10_000).some((a: ModAuditDto) => a.action === 'disable')-- this.store.mods.setStatus(id, status, at)++ (await this.store.mods.auditLog(id, 10_000)).some(++ (a: ModAuditDto) => a.action === 'disable',++ )++ await this.store.mods.setStatus(id, status, at)const action = status === 'enabled' ? 'enable' : 'disable'-- this.writeAudit(id, action, null, actor, null)-- this.emit(status === 'enabled' ? 'enabled' : 'disabled', view.mod, actor, {})++ await this.writeAudit(id, action, null, actor, null)++ await this.emit(t, status === 'enabled' ? 'enabled' : 'disabled', view.mod, actor, {})if (status === 'disabled' || restoring) {-- this.moderated(`mod.${status}`, view, actor, undefined, { previous: view.mod.status })++ await this.moderated(t, `mod.${status}`, view, actor, undefined, {++ previous: view.mod.status,++ })}})return this.refresh(id)@@@ -386,14 +508,14 @@ export class ModRegistry {return view}-- private writeAudit(++ private async writeAudit(id: string,action: string,capability: string | null,actor: ModActor,detail: Record<string, unknown> | null,-- ): void {-- this.store.mods.audit({++ ): Promise<void> {++ await this.store.mods.audit({modId: id,action,capability,@@@ -404,17 +526,19 @@ export class ModRegistry {}/** games.moderation.action, unless the publisher is acting on their own mod. */-- private moderated(++ private async moderated(++ t: Tx,action: string,view: ModView,actor: ModActor,reason: string | undefined,details: Record<string, unknown>,-- ): void {++ ): Promise<void> {const publisher = view.manifest.publisherconst owner = publisher && publisher.type === 'user' ? publisher.id : nullif (owner && actor.subject.type === 'user' && actor.subject.id === owner) return-- this.events.enqueue(++ await this.events.enqueue(++ t,moderationEvent(action,{ type: 'mod', id: view.mod.id, ownerSubject: owner },@@@ -427,25 +551,25 @@ export class ModRegistry {)}-- private emit(++ private async emit(++ t: Tx,kind: ModLifecycle,mod: ModInstallDto,actor: ModActor,detail: Record<string, unknown>,-- ): void {-- this.events.enqueue(modEvent(kind, mod, actor.subject, detail))++ ): Promise<void> {++ await this.events.enqueue(t, modEvent(kind, mod, actor.subject, detail))}/** Re-reads one install after a committed change. */-- private refresh(id: string): ModView {-- const mod = this.store.mods.get(id)++ private async refresh(id: string): Promise<ModView> {++ const mod = await this.store.mods.get(id)if (!mod) {this.views.delete(id)throw new ModError('mod.not_found', `no mod ${id}`, 404)}const granted = new Set(-- this.store.mods-- .grants(id)++ (await this.store.mods.grants(id)).filter((g: ModGrantDto) => g.revokedAt === null).map((g) => g.capability),)@@@ -1,7 +1,7 @@import { createServer, type IncomingMessage, type Server } from 'node:http'import type { AddressInfo } from 'node:net'import { createContent } from '@openvibe/content'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { openTestStore } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'import { verifyUserToken } from 'openvibe-sdk/auth'import { createMockPlatform } from 'openvibe-sdk/testing'@@@ -25,7 +25,8 @@ afterEach(() => {})async function start(authorize: (req: IncomingMessage) => Promise<ModActor | null>) {-- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())++ const registry = new ModRegistry(await openTestStore(), createContent())++ await registry.load()server = createServer((req, res) => {if (!handleModsRequest(req, res, { registry, authorize, log })) {res.writeHead(404)@@@ -167,7 +167,9 @@ export function handleModsRequest(if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {const limit = Number(url.searchParams.get('limit') ?? 100)if (!api.registry.get(id)) return problem(res, 404, 'mod.not_found', 'no such mod')-- json(res, 200, { audit: api.registry.auditLog(id, Number.isFinite(limit) ? limit : 100) })++ json(res, 200, {++ audit: await api.registry.auditLog(id, Number.isFinite(limit) ? limit : 100),++ })return}if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {@@@ -177,7 +179,7 @@ export function handleModsRequest(const principal = await api.grants.change(id, cap, 'revoke', actor.audit)api.registry.noteNetworkRevision(id, principal.revision)}-- json(res, 200, publicView(api.registry.revokeGrant(id, cap, actor)))++ json(res, 200, publicView(await api.registry.revokeGrant(id, cap, actor)))return}if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)@@@ -195,12 +197,12 @@ export function handleModsRequest(let revision = 0if (api.grants) {// Network registers mod:<id> first; what it approved is what is granted here.-- const checked = api.registry.checkInstall(request)++ const checked = await api.registry.checkInstall(request)const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)request.approve = principal.status === 'active' ? principal.approved : []revision = principal.revision}-- const view = api.registry.install(request, actor)++ const view = await api.registry.install(request, actor)if (revision) api.registry.noteNetworkRevision(view.mod.id, revision)api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })json(res, 201, publicView(view))@@@ -208,7 +210,9 @@ export function handleModsRequest(}if (parts.length === 2 && (parts[1] === 'enable' || parts[1] === 'disable')) {const view =-- parts[1] === 'enable' ? api.registry.enable(id, actor) : api.registry.disable(id, actor)++ parts[1] === 'enable'++ ? await api.registry.enable(id, actor)++ : await api.registry.disable(id, actor)api.log.info(`mod ${parts[1]}d`, { mod: id, by: actor.audit })json(res, 200, publicView(view))return@@@ -221,7 +225,7 @@ export function handleModsRequest((await api.grants.revoke(id, actor.audit, reason)).revision,)}-- const view = api.registry.revoke(id, actor, reason)++ const view = await api.registry.revoke(id, actor, reason)api.log.info('mod revoked', { mod: id, by: actor.audit })json(res, 200, publicView(view))return@@@ -231,11 +235,11 @@ export function handleModsRequest(return problem(res, 400, 'mod.invalid_request', 'capability is required')}if (api.grants) {-- api.registry.checkGrant(id, b.capability)++ await api.registry.checkGrant(id, b.capability)const principal = await api.grants.change(id, b.capability, 'approve', actor.audit)api.registry.noteNetworkRevision(id, principal.revision)}-- json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))++ json(res, 200, publicView(await api.registry.grant(id, b.capability, actor)))return}problem(res, 404, 'request.not_found', 'no such mods route')@@@ -1,8 +1,6 @@--import { mkdtempSync } from 'node:fs'--import { tmpdir } from 'node:os'--import { join } from 'node:path'import { createContent } from '@openvibe/content'--import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'++import type { PgPersistenceStore } from '@openvibe/persistence'++import { openTestStore } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'import { describe, expect, it } from 'vitest'import type { EventSink } from '../platform/gameEvents.js'@@@ -46,25 +44,53 @@ function fakeHost() {function captureSink(): EventSink & { types: string[] } {const types: string[] = []-- return { enabled: true, types, enqueue: (e) => types.push(e.event_type) }++ return {++ enabled: true,++ types,++ enqueue: async (_t, e) => {++ types.push(e.event_type)++ },++ }++}++++/** A store whose mods.setStatus rejects while `fail()` is true (the disable write never lands). */++function failingDisableStore(store: PgPersistenceStore, fail: () => boolean): PgPersistenceStore {++ const wrapped = Object.create(store) as PgPersistenceStore++ Object.defineProperty(wrapped, 'mods', {++ value: new Proxy(store.mods, {++ get(target, prop, receiver) {++ if (prop === 'setStatus') {++ return async (...args: unknown[]): Promise<void> => {++ if (fail()) throw new Error('db down')++ return (target.setStatus as (...a: unknown[]) => Promise<void>)(...args)++ }++ }++ return Reflect.get(target, prop, receiver)++ },++ }),++ })++ return wrapped}--function setup(store: SqlitePersistenceStore = openSqliteStore(':memory:')) {++const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 0))++++async function setup(store?: PgPersistenceStore) {++ const db = store ?? (await openTestStore())let now = 1_000_000const clock = { now: () => now, advance: (ms: number) => (now += ms) }const sink = captureSink()-- const registry = new ModRegistry(store, content, sink, clock.now)-- const runtime = new ModRuntime(registry, store, content, log, {++ const registry = new ModRegistry(db, content, sink, clock.now)++ const runtime = new ModRuntime(registry, content, log, {reconcileEveryTicks: 30,now: clock.now,})-- return { store, registry, runtime, sink, clock }++ return { store: db, registry, runtime, sink, clock }}describe('mod registry', () => {-- it('installs with the approved subset only and audits install + grants', () => {-- const { registry, sink } = setup()-- const view = registry.install(++ it('installs with the approved subset only and audits install + grants', async () => {++ const { registry, sink } = await setup()++ const view = await registry.install({ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },STAFF,)@@@ -72,10 +98,7 @@ describe('mod registry', () => {expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(true)expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)expect(-- registry-- .auditLog(MOD_ID)-- .map((a) => [a.action, a.capability])-- .reverse(),++ (await registry.auditLog(MOD_ID)).map((a) => [a.action, a.capability]).reverse(),).toEqual([['install', null],['grant', CAP_PLACE_PROP],@@@ -84,26 +107,26 @@ describe('mod registry', () => {expect(sink.types).toEqual(['games.mod.installed'])})-- it('refuses grants that were not requested or have no binding', () => {-- const { registry } = setup()++ it('refuses grants that were not requested or have no binding', async () => {++ const { registry } = await setup()const manifest = sampleManifest({permissions: { capabilities: [CAP_ANNOUNCE, 'media.object.read'] },})-- expect(() =>++ await expect(registry.install({ manifest, pack: {}, approve: [CAP_PLACE_PROP] }, STAFF),-- ).toThrow(ModError)-- expect(() =>++ ).rejects.toThrow(ModError)++ await expect(registry.install({ manifest, pack: {}, approve: ['media.object.read'] }, STAFF),-- ).toThrow(/no binding/)++ ).rejects.toThrow(/no binding/)// A pack section needs its capability to at least be requested.-- expect(() =>++ await expect(registry.install({ manifest, pack: { props: PACK.props }, approve: [] }, STAFF),-- ).toThrow(/does not request/)++ ).rejects.toThrow(/does not request/)})-- it('never lets a trust tier stand in for a grant', () => {-- const { registry } = setup()-- registry.install(++ it('never lets a trust tier stand in for a grant', async () => {++ const { registry } = await setup()++ await registry.install({manifest: sampleManifest(),pack: PACK,@@@ -118,21 +141,21 @@ describe('mod registry', () => {expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(false)})-- it('refuses executable runtimes until sandboxed execution exists', () => {-- const { registry } = setup()-- expect(() =>++ it('refuses executable runtimes until sandboxed execution exists', async () => {++ const { registry } = await setup()++ await expect(registry.install({ manifest: sampleManifest({ runtime: 'source-quickjs@1' }), pack: {}, approve: [] },STAFF,),-- ).toThrow(/cannot run here/)++ ).rejects.toThrow(/cannot run here/)})})describe('mod runtime seam', () => {-- it('a denied capability cannot be reached: the binding throws and the host is never called', () => {-- const { registry, runtime, clock } = setup()-- registry.install(++ it('a denied capability cannot be reached: the binding throws and the host is never called', async () => {++ const { registry, runtime, clock } = await setup()++ await registry.install({ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },STAFF,)@@@ -156,13 +179,14 @@ describe('mod runtime seam', () => {runtime.tick(t, host)}expect(said).toEqual([])-- const denials = registry.auditLog(MOD_ID).filter((a) => a.action === 'deny')++ await registry.flushWrites()++ const denials = (await registry.auditLog(MOD_ID)).filter((a) => a.action === 'deny')expect(denials.map((a) => a.capability)).toEqual([CAP_ANNOUNCE])})-- it('places the pack, and a revoked mod stops affecting the world on the next tick', () => {-- const { registry, runtime, clock, sink } = setup()-- registry.install(++ it('places the pack, and a revoked mod stops affecting the world on the next tick', async () => {++ const { registry, runtime, clock, sink } = await setup()++ await registry.install({manifest: sampleManifest(),pack: PACK,@@@ -181,7 +205,7 @@ describe('mod runtime seam', () => {runtime.tick(31, host)expect(said).toEqual(['[Town Square] Welcome to the square'])-- registry.revoke(MOD_ID, STAFF, 'test')++ await registry.revoke(MOD_ID, STAFF, 'test')runtime.tick(32, host) // the very next tickexpect(entities.size).toBe(0)clock.advance(600_000)@@@ -192,19 +216,20 @@ describe('mod runtime seam', () => {ModCapabilityError,)// Revoked is terminal.-- expect(() => registry.enable(MOD_ID, STAFF)).toThrow(/stays revoked/)-- expect(() => registry.grant(MOD_ID, CAP_ANNOUNCE, STAFF)).toThrow(/revoked/)++ await expect(registry.enable(MOD_ID, STAFF)).rejects.toThrow(/stays revoked/)++ await expect(registry.grant(MOD_ID, CAP_ANNOUNCE, STAFF)).rejects.toThrow(/revoked/)-- const actions = registry.auditLog(MOD_ID).map((a) => a.action)++ await registry.flushWrites()++ const actions = (await registry.auditLog(MOD_ID)).map((a) => a.action)for (const a of ['install', 'grant', 'use', 'revoke_grant', 'revoke', 'retract']) {expect(actions, a).toContain(a)}expect(sink.types).toEqual(['games.mod.installed', 'games.mod.revoked'])})-- it('revoking one capability retracts only what it allowed', () => {-- const { registry, runtime, sink } = setup()-- registry.install(++ it('revoking one capability retracts only what it allowed', async () => {++ const { registry, runtime, sink } = await setup()++ await registry.install({manifest: sampleManifest(),pack: PACK,@@@ -216,11 +241,11 @@ describe('mod runtime seam', () => {const { host, entities } = fakeHost()runtime.tick(1, host)expect(entities.size).toBe(2)-- registry.revokeGrant(MOD_ID, CAP_PLACE_PROP, STAFF)++ await registry.revokeGrant(MOD_ID, CAP_PLACE_PROP, STAFF)runtime.tick(2, host)expect(entities.size).toBe(0)expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)-- registry.grant(MOD_ID, CAP_PLACE_PROP, STAFF)++ await registry.grant(MOD_ID, CAP_PLACE_PROP, STAFF)runtime.tick(3, host)expect(entities.size).toBe(2)expect(sink.types).toEqual([@@@ -230,18 +255,18 @@ describe('mod runtime seam', () => {])})-- it('disable retracts, enable restores, and a missing prop is placed again', () => {-- const { registry, runtime } = setup()-- registry.install(++ it('disable retracts, enable restores, and a missing prop is placed again', async () => {++ const { registry, runtime } = await setup()++ await registry.install({ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },STAFF,)const { host, entities } = fakeHost()runtime.tick(1, host)-- registry.disable(MOD_ID, STAFF)++ await registry.disable(MOD_ID, STAFF)runtime.tick(2, host)expect(entities.size).toBe(0)-- registry.enable(MOD_ID, STAFF)++ await registry.enable(MOD_ID, STAFF)runtime.tick(3, host)expect(entities.size).toBe(2)const [first] = [...entities.keys()]@@@ -250,28 +275,113 @@ describe('mod runtime seam', () => {expect(entities.size).toBe(1)runtime.tick(3 + 30, host)expect(entities.size).toBe(2)++ await registry.flushWrites()++ })++++ it('a failed disable write keeps an over-budget mod running: strikes are not cleared', async () => {++ const store = await openTestStore()++ let fail = true++ const { registry, runtime } = await setup(failingDisableStore(store, () => fail))++ await registry.install(++ {++ manifest: sampleManifest({ resources: { cpuMs: 0, memoryMb: 0, storageMb: 0 } }),++ pack: PACK,++ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],++ enable: true,++ },++ STAFF,++ )++ const { host } = fakeHost()++ // Drive enough reconciles for CPU_STRIKES to trip the budget; let each failed disable settle so the++ // next reconcile enforces again instead of being suppressed as already in flight.++ for (let t = 1; t <= 200; t += 30) {++ runtime.tick(t, host)++ await settle()++ }++ await registry.flushWrites()++ expect(registry.isActive(MOD_ID)).toBe(true) // the disable never landed++ const enforced = (await registry.auditLog(MOD_ID)).filter(++ (a) => a.action === 'budget_enforced',++ ).length++ expect(enforced).toBeGreaterThanOrEqual(2) // it kept being enforced, not reset to a clean slate++++ // The write lands now: the next reconcile disables it for good.++ fail = false++ for (const t of [211, 241, 271, 301]) {++ runtime.tick(t, host)++ await settle()++ }++ expect(registry.isActive(MOD_ID)).toBe(false)++ await registry.flushWrites()++ })++++ it('a placement write that keeps failing rolls the mirror back and the next tick re-places', async () => {++ const store = await openTestStore()++ let fail = true++ const wrapped = {++ ...store,++ mods: {++ ...store.mods,++ setPlacement: async (placement: Parameters<typeof store.mods.setPlacement>[0]) => {++ if (fail) throw new Error('db down')++ await store.mods.setPlacement(placement)++ },++ },++ } as PgPersistenceStore++ const { registry, runtime } = await setup(wrapped)++ await registry.install(++ {++ manifest: sampleManifest(),++ pack: PACK,++ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],++ enable: true,++ },++ STAFF,++ )++ const { host } = fakeHost()++ runtime.tick(1, host)++ await registry.flushWrites()++ // The writes gave up: the mirror was rolled back so it agrees with the database (empty).++ expect(registry.placements(MOD_ID)).toEqual([])++ expect(registry.writeFailures).toBeGreaterThanOrEqual(1)++ expect(await store.mods.placements(MOD_ID)).toEqual([])++++ // The database recovers: the next reconcile places the props again and the rows land.++ fail = false++ runtime.tick(31, host)++ await registry.flushWrites()++ expect(++ registry++ .placements(MOD_ID)++ .map((p) => p.key)++ .sort(),++ ).toEqual(['bench', 'fire'])++ expect((await store.mods.placements(MOD_ID)).map((p) => p.key).sort()).toEqual([++ 'bench',++ 'fire',++ ])})-- it('revocation survives a restart: the next boot retracts what the old process left', () => {-- const dir = mkdtempSync(join(tmpdir(), 'openvibe-mods-'))-- const path = join(dir, 'world.db')-- const a = setup(openSqliteStore(path))-- a.registry.install(++ it('revocation survives a restart: the next boot retracts what the old process left', async () => {++ const store = await openTestStore()++ const a = await setup(store)++ await a.registry.install({ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },STAFF,)const world = fakeHost()a.runtime.tick(1, world.host)expect(world.entities.size).toBe(2)-- a.registry.revoke(MOD_ID, STAFF)++ await a.registry.revoke(MOD_ID, STAFF)// The process dies before its next tick: the props are still in the world.-- a.store.close()++ await a.registry.flushWrites()expect(world.entities.size).toBe(2)-- const b = setup(openSqliteStore(path))++ const b = await setup(store)++ await b.registry.load()expect(b.registry.get(MOD_ID)?.mod.status).toBe('revoked')b.runtime.tick(1, world.host)expect(world.entities.size).toBe(0)-- b.store.close()++ await b.registry.flushWrites()})})@@@ -28,7 +28,6 @@* on that very tick.*/import type { ContentRegistry } from '@openvibe/content'--import type { PersistenceStore } from '@openvibe/persistence'import type { Logger } from '@openvibe/shared'import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'import { SYSTEM_ACTOR, type ModRegistry, type ModView } from './registry.js'@@@ -98,6 +97,8 @@ export class ModRuntime {private readonly budgetNotedAt = new Map<string, number>()/** Reconciles in a row over the CPU budget, per mod. */private readonly cpuStrikes = new Map<string, number>()++ /** Mods whose disable write is in flight (an over-budget mod is enforced once, not every tick). */++ private readonly enforcing = new Set<string>()/** Host effects per mod in the current minute, and minutes in a row that were throttled. */private readonly effects = new Map<string,@@@ -107,7 +108,6 @@ export class ModRuntime {constructor(private readonly registry: ModRegistry,-- private readonly store: PersistenceStore,private readonly content: ContentRegistry,private readonly log: Logger,private readonly opts: ModRuntimeOptions,@@@ -143,21 +143,21 @@ export class ModRuntime {if (!pos.every((v) => Number.isFinite(v)) || !Number.isFinite(yaw)) {throw new Error('position must be finite')}-- const existing = this.store.mods.placements(modId).find((p) => p.key === key)++ const existing = this.registry.placements(modId).find((p) => p.key === key)if (existing?.entityId && host.entityExists(existing.entityId)) {host.removeEntity(existing.entityId)}const entityId = host.placeProp({ item, pos: [pos[0], pos[1], pos[2]], yaw, owner: modId })-- this.store.mods.setPlacement({ modId, key, entityId, at: this.now() })++ this.registry.place(modId, key, entityId)this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, item, entity: entityId })return entityId},removeProp: (key) => {check(CAP_PLACE_PROP)-- const existing = this.store.mods.placements(modId).find((p) => p.key === key)++ const existing = this.registry.placements(modId).find((p) => p.key === key)if (!existing) return falseif (existing.entityId) host.removeEntity(existing.entityId)-- this.store.mods.deletePlacement(modId, key)++ this.registry.unplace(modId, key)this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, removed: true })return true},@@@ -198,7 +198,7 @@ export class ModRuntime {this.retractProps(id, host)if (props.length > 0) this.noteDenied(id, CAP_PLACE_PROP)} else {-- const placed = new Map(this.store.mods.placements(id).map((p) => [p.key, p]))++ const placed = new Map(this.registry.placements(id).map((p) => [p.key, p]))const wanted = new Set(props.map((p) => p.key))for (const prop of props) {const p = placed.get(prop.key)@@@ -276,22 +276,36 @@ export class ModRuntime {reason: 'cpu' | 'event_flood',detail: Record<string, unknown>,): void {-- if (!this.registry.isActive(id)) return++ if (!this.registry.isActive(id) || this.enforcing.has(id)) returnthis.registry.audit(id, 'budget_enforced', null, { reason, ...detail })-- this.registry.disable(id, SYSTEM_ACTOR)-- this.cpuStrikes.delete(id)-- this.effects.delete(id)-- this.log.warn('mod disabled: over its budget', { mod: id, reason })++ // The registry write is async; the tick never awaits it. Strikes and the effects window are cleared++ // only once the disable committed: if the write fails the mod keeps them, so the next reconcile++ // enforces again instead of leaving an over-budget mod running with a clean slate.++ this.enforcing.add(id)++ void this.registry++ .disable(id, SYSTEM_ACTOR)++ .then(() => {++ this.cpuStrikes.delete(id)++ this.effects.delete(id)++ this.log.warn('mod disabled: over its budget', { mod: id, reason })++ })++ .catch((err: unknown) => {++ this.log.warn('mod disable failed', {++ mod: id,++ error: String((err as Error)?.message ?? err),++ })++ })++ .finally(() => this.enforcing.delete(id))}/** Despawns everything the mod placed. */private retractProps(id: string, host: ModHost): void {-- const placements = this.store.mods.placements(id)++ const placements = [...this.registry.placements(id)]if (placements.length === 0) returnlet removed = 0for (const p of placements) {if (p.entityId && host.removeEntity(p.entityId)) removed++-- this.store.mods.deletePlacement(id, p.key)++ this.registry.unplace(id, p.key)}this.registry.audit(id, 'retract', CAP_PLACE_PROP, { placements: placements.length, removed })this.log.info('mod props retracted', { mod: id, removed })@@@ -1,7 +1,7 @@import { createContent } from '@openvibe/content'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { openTestStore } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'--import { describe, expect, it } from 'vitest'++import { describe, expect, it, vi } from 'vitest'import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'import { ModRegistry, type ModActor } from './registry.js'import {@@@ -55,12 +55,12 @@ function world(opts: { slowMs?: number } = {}) {return { host, entities, said }}--function setup(effectsPerMinute = 60) {++async function setup(effectsPerMinute = 60) {let now = 1_000_000const clock = { now: () => now, advance: (ms: number) => (now += ms) }-- const store = openSqliteStore(':memory:')++ const store = await openTestStore()const registry = new ModRegistry(store, content, undefined, clock.now)-- const runtime = new ModRuntime(registry, store, content, log, {++ const runtime = new ModRuntime(registry, content, log, {reconcileEveryTicks: 1,now: clock.now,effectsPerMinute,@@@ -85,11 +85,11 @@ const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({})describe('mod security', () => {-- it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {-- const { registry, runtime, install } = setup()++ it('namespace escape: a mod reaches only its own placements, through frozen bindings', async () => {++ const { registry, runtime, install } = await setup()const w = world()-- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])-- install(B, { props: [prop('bench', [20, 0, 20])] }, [CAP_PLACE_PROP])++ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])++ await install(B, { props: [prop('bench', [20, 0, 20])] }, [CAP_PLACE_PROP])runtime.tick(1, w.host)expect([...w.entities.values()].map((e) => e.owner).sort()).toEqual([A, B])const apiA = runtime.bindingsFor(A, w.host)@@@ -101,10 +101,11 @@ describe('mod security', () => {expect(apiA.removeProp('bench')).toBe(true)expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])expect(apiA.removeProp('bench')).toBe(false)++ await registry.flushWrites()expect(-- registry-- .auditLog(B)-- .some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed')),++ (await registry.auditLog(B)).some(++ (a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed'),++ ),).toBe(false)// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install// and at the binding.@@@ -116,11 +117,11 @@ describe('mod security', () => {expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)})-- it('event flood: past the allowance calls are refused, and only the flooding mod is disabled', () => {-- const { registry, runtime, clock, install } = setup(10)++ it('event flood: past the allowance calls are refused, and only the flooding mod is disabled', async () => {++ const { registry, runtime, clock, install } = await setup(10)const w = world()-- install(A, { announcements: [{ text: 'spam', everySeconds: 60 }] }, [CAP_ANNOUNCE])-- install(B, { announcements: [{ text: 'hello', everySeconds: 60 }] }, [CAP_ANNOUNCE])++ await install(A, { announcements: [{ text: 'spam', everySeconds: 60 }] }, [CAP_ANNOUNCE])++ await install(B, { announcements: [{ text: 'hello', everySeconds: 60 }] }, [CAP_ANNOUNCE])const apiA = runtime.bindingsFor(A, w.host)const apiB = runtime.bindingsFor(B, w.host)let refused = 0@@@ -133,7 +134,8 @@ describe('mod security', () => {}expect(w.said.length).toBe(10)expect(refused).toBe(15)-- expect(registry.auditLog(A).filter((a) => a.action === 'throttled').length).toBe(1)++ await registry.flushWrites()++ expect((await registry.auditLog(A)).filter((a) => a.action === 'throttled').length).toBe(1)apiB.announce('still here')expect(w.said.at(-1)).toContain('still here')// Flooding minute after minute: the mod is disabled, the other is not.@@@ -147,39 +149,42 @@ describe('mod security', () => {}}}-- expect(registry.get(A)?.mod.status).toBe('disabled')++ // The disable the flood triggers is a fire-and-forget registry write; wait for it to commit.++ await vi.waitFor(() => expect(registry.get(A)?.mod.status).toBe('disabled'))++ await registry.flushWrites()expect(-- registry-- .auditLog(A)-- .some(-- (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),-- ),++ (await registry.auditLog(A)).some(++ (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),++ ),).toBe(true)expect(registry.get(B)?.mod.status).toBe('enabled')expect(() => apiA.announce('after')).toThrow()})-- it('resource budget: a mod over its CPU budget reconcile after reconcile is disabled; the others run', () => {-- const { registry, runtime, install } = setup()++ it('resource budget: a mod over its CPU budget reconcile after reconcile is disabled; the others run', async () => {++ const { registry, runtime, install } = await setup()const slow = world({ slowMs: 3 })-- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP], 1)-- install(B, { props: [] }, [], 50)++ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP], 1)++ await install(B, { props: [] }, [], 50)// A's prop keeps vanishing, so every reconcile places it again: slow, over its 1 ms budget.for (let t = 1; t <= CPU_STRIKES + 2; t++) {slow.entities.clear()runtime.tick(t, slow.host)}-- expect(registry.get(A)?.mod.status).toBe('disabled')-- const enforced = registry.auditLog(A).find((a) => a.action === 'budget_enforced')++ // The disable the budget triggers is a fire-and-forget registry write; wait for it to commit.++ await vi.waitFor(() => expect(registry.get(A)?.mod.status).toBe('disabled'))++ await registry.flushWrites()++ const enforced = (await registry.auditLog(A)).find((a) => a.action === 'budget_enforced')expect(enforced?.detail).toMatchObject({ reason: 'cpu', budget_ms: 1, reconciles: CPU_STRIKES })expect(registry.get(B)?.mod.status).toBe('enabled')// Disabled means retracted on the next reconcile.runtime.tick(CPU_STRIKES + 3, slow.host)expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])++ await registry.flushWrites()})-- it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", () => {-- const { registry, runtime, install } = setup()++ it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", async () => {++ const { registry, runtime, install } = await setup()const w = world()expect(validateContentPack(@@@ -187,12 +192,12 @@ describe('mod security', () => {content,).ok,).toBe(false)-- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])++ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])runtime.tick(1, w.host)expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])// A principal Games does not have, or that another runtime owns, is not applied here.expect(-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: B,status: 'active',approved: [CAP_PLACE_PROP, CAP_ANNOUNCE],@@@ -202,7 +207,7 @@ describe('mod security', () => {// A grant the manifest never requested cannot be applied, even from Network.const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')-- registry.applyNetwork({++ await registry.applyNetwork({mod_id: A,status: 'active',approved: [CAP_PLACE_PROP, 'games.world.teleport'],@@@ -9,7 +9,8 @@*/import { createHash } from 'node:crypto'import type { IncomingMessage, ServerResponse } from 'node:http'--import { createActorLimiter } from 'openvibe-sdk/limits'++import { createActorLimiter, createValkeyLimitStore } from 'openvibe-sdk/limits'++import type { Valkey } from 'openvibe-sdk/valkey'type Req = IncomingMessage & { ovActor?: string }const num = (v: string | undefined, d: number): number => {@@@ -22,13 +23,17 @@ export function createGamesActorLimits(env?: NodeJS.ProcessEnvnow?: () => numberonLimited?: (name: string, actor: string) => void++ /** Shared Valkey: one actor counts the same across every instance; null = in-process counters. */++ valkey?: Valkey | null} = {},) {const env = opts.env ?? process.env++ const store = opts.valkey ? createValkeyLimitStore(opts.valkey) : nullconst limiter = createActorLimiter({limits: { minute: num(env.GAMES_LIMITS_MINUTE, 120), hour: num(env.GAMES_LIMITS_HOUR, 3000) },actor: (req) => (req as Req).ovActor ?? null,...(opts.now ? { now: opts.now } : {}),++ ...(store ? { store } : {}),onLimited: (e) => opts.onLimited?.(e.name, e.actor),})// A map save validates and migrates the whole map and swaps it live; an asset is hashed and stored on disk.@@@ -90,7 +90,7 @@ beforeAll(async () => {metrics.tick = 42metrics.lastTickAt = Date.now()// /api/ready arrives through the platform hook, as main.ts wires it.-- const readiness = createReadiness({ pingDb: () => true, metrics })++ const readiness = createReadiness({ pingDb: async () => ({ ok: true }), metrics })server = createHttpServer(dist,metrics,@@@ -18,7 +18,12 @@ export class ServerMetrics {bytesOut = 0messagesOut = 0snapshotBytes = 0++ /** Write-behind flusher: jobs queued or running, rows written, duration and failure counters. */dbDirtyQueue = 0++ persistRowsWritten = 0++ persistFlushMs = 0++ persistFlushFailures = 0++ persistCoalesced = 0/** Live constraints and the multi-prop structures they form. */constraints = 0constraintIslands = 0@@@ -59,6 +64,10 @@ export class ServerMetrics {messagesOut: this.messagesOut,snapshotBytes: this.snapshotBytes,dbDirtyQueue: this.dbDirtyQueue,++ persistRowsWritten: this.persistRowsWritten,++ persistFlushMs: round2(this.persistFlushMs),++ persistFlushFailures: this.persistFlushFailures,++ persistCoalesced: this.persistCoalesced,constraints: this.constraints,constraintIslands: this.constraintIslands,activeRegions: this.activeRegions,@@@ -1,44 +1,26 @@--import { mkdtempSync, rmSync } from 'node:fs'import { createServer, type Server } from 'node:http'import type { AddressInfo } from 'node:net'--import { tmpdir } from 'node:os'--import { join } from 'node:path'--import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'import { afterEach, beforeEach, describe, expect, it } from 'vitest'import { TICK_STALL_MS, createReadiness } from './readiness.js'const T0 = 1_750_000_000_000--let dir: string--let store: SqlitePersistenceStore++type PingDb = () => Promise<{ ok: boolean; error?: string }>--beforeEach(() => {-- dir = mkdtempSync(join(tmpdir(), 'ovg-ready-'))-- store = openSqliteStore(join(dir, 'world.db'))--})----afterEach(() => {-- try {-- store.close()-- } catch {-- // already closed by the test-- }-- rmSync(dir, { recursive: true, force: true })--})++const up: PingDb = async () => ({ ok: true })--/** The same wiring main.ts uses, over a real world.db and a controllable clock. */--function setup(metrics: { tick: number; lastTickAt: number }, clock = { now: T0 }) {-- const stmt = store.db.prepare('SELECT value FROM meta WHERE key = ?')-- return createReadiness({-- pingDb: () => stmt.get('schema_version') !== undefined,-- metrics,-- now: () => clock.now,-- })++/** The same wiring main.ts uses, over a controllable database ping and a controllable clock. */++function setup(++ metrics: { tick: number; lastTickAt: number },++ clock = { now: T0 },++ pingDb: PingDb = up,++) {++ return createReadiness({ pingDb, metrics, now: () => clock.now })}describe('/api/ready', () => {-- it('is ready when world.db answers and the tick is recent', () => {-- const body = setup({ tick: 120, lastTickAt: T0 - 40 }).check()++ it('is ready when the database answers and the tick is recent', async () => {++ const body = await setup({ tick: 120, lastTickAt: T0 - 40 }).check()expect(body).toMatchObject({ready: true,status: 'ready',@@@ -54,57 +36,69 @@ describe('/api/ready', () => {})})-- it('reports the players online as a non-required sessions check (for the host protected probe)', () => {-- const stmt = store.db.prepare('SELECT value FROM meta WHERE key = ?')++ it('reports the players online as a non-required sessions check (for the host protected probe)', async () => {let online = 3-- const r = createReadiness({ pingDb: () => stmt.get('schema_version') !== undefined, metrics: { tick: 5, lastTickAt: T0 - 10 }, now: () => T0, online: () => online })-- let body = r.check()-- expect(body.checks.sessions).toMatchObject({ status: 'ok', required: false, detail: { online: 3 } })++ const r = createReadiness({++ pingDb: up,++ metrics: { tick: 5, lastTickAt: T0 - 10 },++ now: () => T0,++ online: () => online,++ })++ let body = await r.check()++ expect(body.checks.sessions).toMatchObject({++ status: 'ok',++ required: false,++ detail: { online: 3 },++ })expect(body.status).toBe('ready')online = 0-- body = r.check()++ body = await r.check()expect(body.checks.sessions?.detail).toEqual({ online: 0 })-- expect(setup({ tick: 5, lastTickAt: T0 - 10 }).check().checks.sessions).toBeUndefined()++ expect((await setup({ tick: 5, lastTickAt: T0 - 10 }).check()).checks.sessions).toBeUndefined()})-- it('is not ready before the first tick', () => {-- const body = setup({ tick: 0, lastTickAt: 0 }).check()++ it('is not ready before the first tick', async () => {++ const body = await setup({ tick: 0, lastTickAt: 0 }).check()expect(body.status).toBe('not_ready')expect(body.failed).toEqual(['tick'])expect(body.checks.tick?.error).toBe('simulation has not ticked yet')})-- it('is not ready once the tick stops advancing', () => {++ it('is not ready once the tick stops advancing', async () => {const metrics = { tick: 300, lastTickAt: T0 }const clock = { now: T0 + TICK_STALL_MS }const ready = setup(metrics, clock)-- expect(ready.check().ready).toBe(true)++ expect((await ready.check()).ready).toBe(true)clock.now = T0 + TICK_STALL_MS + 1-- const body = ready.check()++ const body = await ready.check()expect(body.ready).toBe(false)expect(body.failed).toEqual(['tick'])expect(body.checks.tick?.error).toMatch(/no simulation tick for \d+ ms/)// The tick moves again: ready again.metrics.tick = 301metrics.lastTickAt = clock.now-- expect(ready.check().ready).toBe(true)++ expect((await ready.check()).ready).toBe(true)})-- it('is not ready when world.db stops answering', () => {-- const ready = setup({ tick: 5, lastTickAt: T0 })-- store.close()-- const body = ready.check()++ it('is not ready when the database stops answering', async () => {++ let dbUp = true++ const ready = setup({ tick: 5, lastTickAt: T0 }, { now: T0 }, async () =>++ dbUp ? { ok: true } : { ok: false, error: 'connection refused' },++ )++ dbUp = false++ const body = await ready.check()expect(body.status).toBe('not_ready')expect(body.failed).toEqual(['db'])expect(body.checks.db?.status).toBe('fail')-- expect(body.checks.db?.error).toMatch(/not open/)++ expect(body.checks.db?.error).toMatch(/connection refused/)})-- it('is not ready when the query returns nothing', () => {-- store.db.prepare('DELETE FROM meta').run()-- const body = setup({ tick: 5, lastTickAt: T0 }).check()++ it('is not ready when the ping fails without a reason', async () => {++ const body = await setup({ tick: 5, lastTickAt: T0 }, { now: T0 }, async () => ({++ ok: false,++ })).check()expect(body.failed).toEqual(['db'])-- expect(body.checks.db?.error).toBe('world.db query returned nothing')++ expect(body.checks.db?.error).toBe('database did not answer')})describe('over HTTP', () => {@@@ -145,6 +139,35 @@ describe('/api/ready', () => {expect(body.checks.tick.error).toMatch(/no simulation tick/)})++ it('answers 503 rather than hanging when the probe itself rejects', async () => {++ const broken = createReadiness({++ pingDb: up,++ metrics: { tick: 10, lastTickAt: T0 },++ // checked_at is assembled outside run()'s try, so a throwing clock rejects check() outright.++ now: () => {++ throw new Error('clock broke')++ },++ })++ await expect(broken.check()).rejects.toThrow('clock broke')++ const probe = createServer((req, res) => {++ if (broken.handle(req, res)) return++ res.writeHead(404)++ res.end()++ })++ await new Promise<void>((r) => probe.listen(0, '127.0.0.1', r))++ try {++ const res = await fetch(++ `http://127.0.0.1:${(probe.address() as AddressInfo).port}/api/ready`,++ )++ expect(res.status).toBe(503)++ expect(res.headers.get('content-type')).toBe('application/json; charset=utf-8')++ expect(res.headers.get('cache-control')).toBe('no-store')++ expect(await res.json()).toEqual({ error: 'probe_failed' })++ } finally {++ await new Promise<void>((r) => probe.close(() => r()))++ }++ })++it('leaves other paths alone and refuses writes', async () => {expect((await fetch(`${base}/api/readyz`)).status).toBe(404)expect((await fetch(`${base}/api/ready`, { method: 'POST' })).status).toBe(405)@@@ -8,8 +8,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'* 200 when every required check passes, 503 otherwise; each failed check* carries its reason in `error`.*-- * Required: `db` (world.db answers a trivial query) and `tick` (the fixed-tick-- * simulation has advanced recently). /healthz stays liveness only.++ * Required: `db` (the PostgreSQL handle answers a real round trip through db.ready()) and `tick` (the++ * fixed-tick simulation has advanced recently). /healthz stays liveness only.*//**@@@ -41,8 +41,8 @@ export interface ReadinessBody {}export interface ReadinessDeps {-- /** Runs a trivial query against world.db; throws or returns false when it fails. */-- pingDb: () => boolean++ /** A real round trip against the database (openvibe-sdk/db db.ready()). */++ pingDb: () => Promise<{ ok: boolean; error?: string }>/** The tick counter and when the last tick completed (epoch ms, 0 = never). */metrics: { readonly tick: number; readonly lastTickAt: number }/** Players connected now (a non-required `sessions` check; OpenVibe.Host's protected probe reads it). */@@@ -59,11 +59,15 @@ function reason(err: unknown): string {return (text.split('\n')[0] ?? '').slice(0, 200) || 'failed'}--function run(fn: () => Outcome, required: boolean, now: () => number): ReadinessCheck {++async function run(++ fn: () => Outcome | Promise<Outcome>,++ required: boolean,++ now: () => number,++): Promise<ReadinessCheck> {const t0 = performance.now()let out: Outcometry {-- out = fn()++ out = await fn()} catch (err) {out = { ok: false, error: reason(err) }}@@@ -78,39 +82,40 @@ function run(fn: () => Outcome, required: boolean, now: () => number): Readiness}export function createReadiness(deps: ReadinessDeps): {-- check: () => ReadinessBody++ check: () => Promise<ReadinessBody>handle: (req: IncomingMessage, res: ServerResponse) => boolean} {const now = deps.now ?? Date.nowconst stallMs = deps.tickStallMs ?? TICK_STALL_MS-- function check(): ReadinessBody {-- const checks: Record<string, ReadinessCheck> = {-- db: run(-- () =>-- deps.pingDb() ? { ok: true } : { ok: false, error: 'world.db query returned nothing' },-- true,-- now,-- ),-- tick: run(-- () => {-- const { tick, lastTickAt } = deps.metrics-- const at = now()-- const ageMs = lastTickAt > 0 ? at - lastTickAt : null-- const detail = { tick, age_ms: ageMs, threshold_ms: stallMs }-- if (ageMs === null) return { ok: false, error: 'simulation has not ticked yet', detail }-- if (ageMs > stallMs) {-- return { ok: false, error: `no simulation tick for ${Math.round(ageMs)} ms`, detail }-- }-- return { ok: true, detail }-- },-- true,-- now,-- ),-- }++ async function check(): Promise<ReadinessBody> {++ const db = await run(++ async () => {++ const r = await deps.pingDb()++ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'database did not answer' }++ },++ true,++ now,++ )++ const tick = await run(++ () => {++ const { tick, lastTickAt } = deps.metrics++ const at = now()++ const ageMs = lastTickAt > 0 ? at - lastTickAt : null++ const detail = { tick, age_ms: ageMs, threshold_ms: stallMs }++ if (ageMs === null) return { ok: false, error: 'simulation has not ticked yet', detail }++ if (ageMs > stallMs) {++ return { ok: false, error: `no simulation tick for ${Math.round(ageMs)} ms`, detail }++ }++ return { ok: true, detail }++ },++ true,++ now,++ )++ const checks: Record<string, ReadinessCheck> = { db, tick }if (deps.online) {const online = deps.online-- checks.sessions = run(() => ({ ok: true, detail: { online: online() } }), false, now)++ checks.sessions = await run(() => ({ ok: true, detail: { online: online() } }), false, now)}const names = Object.keys(checks)const failed = names.filter((n) => checks[n]?.required && checks[n]?.status !== 'ok')@@@ -134,12 +139,25 @@ export function createReadiness(deps: ReadinessDeps): {res.end('{"error":"method_not_allowed"}')return true}-- const body = check()-- res.writeHead(body.ready ? 200 : 503, {-- 'content-type': 'application/json; charset=utf-8',-- 'cache-control': 'no-store',-- })-- res.end(req.method === 'HEAD' ? undefined : JSON.stringify(body))++ void check()++ .then((body) => {++ if (res.writableEnded) return++ res.writeHead(body.ready ? 200 : 503, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(body))++ })++ // A throw outside run()'s try (body assembly, header writes) must not leave the probe hanging++ // or produce an unhandled rejection: answer not-ready like the check itself would.++ .catch(() => {++ if (res.writableEnded) return++ res.writeHead(503, {++ 'content-type': 'application/json; charset=utf-8',++ 'cache-control': 'no-store',++ })++ res.end(req.method === 'HEAD' ? undefined : JSON.stringify({ error: 'probe_failed' }))++ })return true}@@@ -1,4 +1,4 @@--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import { openTestStore } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'import { createRequire } from 'node:module'import { describe, expect, it } from 'vitest'@@@ -46,8 +46,8 @@ const ev = (type: string, payload: Record<string, unknown>, source = 'network'):describe('account export and deletion (ADR-033)', () => {it('exports, then closes sessions, erases and confirms once', async () => {-- const store = openSqliteStore(':memory:')-- store.players.upsertMany([char('d0', DANA), char('d1', OLD), char('o0', OTTO)])++ const store = await openTestStore()++ await store.players.upsertMany([char('d0', DANA), char('d1', OLD), char('o0', OTTO)])const sent: {path: stringbody: {@@@ -103,8 +103,10 @@ describe('account export and deletion (ADR-033)', () => {failNext = trueawait expect(data.apply(del)).rejects.toThrow(/confirmation refused: 503/)expect(closed).toEqual([[DANA, OLD]])-- expect(store.players.listByToken(DANA).length + store.players.listByToken(OLD).length).toBe(0)-- expect(store.players.listByToken(OTTO).map((p) => p.id)).toEqual(['o0'])++ const dana = await store.players.listByToken(DANA)++ const old = await store.players.listByToken(OLD)++ expect(dana.length + old.length).toBe(0)++ expect((await store.players.listByToken(OTTO)).map((p) => p.id)).toEqual(['o0'])expect(await data.apply(del)).toBe('confirmed')expect(closed.length).toBe(1)const conf = sent[1]!@@@ -116,6 +118,6 @@ describe('account export and deletion (ADR-033)', () => {expect(conf.body.retained).toEqual({ structures_unowned: 0 })expect(await data.apply(del)).toBe('unchanged')expect(sent.length).toBe(2)-- store.close()++ await store.close()})})@@@ -1,17 +1,17 @@/*** Account export and deletion → Games (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at POST* /internal/events (./revocationEvents.ts) and are applied once per export or deletion (account_data_events, kept by-- * this adapter beside the outbox); the delivery is answered after Network took the part or the confirmation, so a-- * failure is redelivered without erasing twice.++ * this adapter beside the outbox; the table is in the persistence migration); the delivery is answered after Network++ * took the part or the confirmation, so a failure is redelivered without erasing twice.** network.account.export_requested Games' part (POST /internal/account-exports/:id/parts, service token):-- * characters.json (without the sign-in token) and structures.json.++ * characters.json (without the account key) and structures.json.* network.account.deleted the person's game sessions are closed first (each close saves its character),* then store.identity.eraseSubjects removes their characters and adoption rows;* their structures stay in the world without an owner. Games then confirms* with counts.*/--import type Database from 'better-sqlite3'++import { sql, type Db } from 'openvibe-sdk/db'import type { Logger } from '@openvibe/shared'import type { IdentityRepository } from '@openvibe/persistence'@@@ -33,31 +33,25 @@ export type Send = (path: string, body: unknown) => Promise<{ ok: boolean; statuinterface Record_ {id: string-- outcome: string | null++ outcome: unknownsent_at: string | nullapplied_at: string}export function createAccountData(opts: {-- db: Database.Database++ db: Dbidentity: IdentityRepositorysend: Send/** Close the subjects' game sessions and resolve once their characters were saved (so the erasure comes after). */closeSessions: (subjects: string[]) => Promise<void>log: Logger}): { apply(event: AccountEvent): Promise<string> } {-- opts.db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL,-- subject TEXT NOT NULL,-- outcome TEXT,-- sent_at TEXT,-- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))-- )`)-- const get = opts.db.prepare<[string], Record_>(-- 'SELECT id, outcome, sent_at, applied_at FROM account_data_events WHERE id = ?',-- )-- const markSent = opts.db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?')++ const get = (id: string): Promise<Record_ | null> =>++ opts.db.maybe<Record_>(++ sql`SELECT id, outcome, sent_at, applied_at FROM account_data_events WHERE id = ${id}`,++ )++ const markSent = (id: string): Promise<number> =>++ opts.db.exec(sql`UPDATE account_data_events SET sent_at = now() WHERE id = ${id}`)async function apply(event: AccountEvent): Promise<string> {if (!event || !ACCOUNT_TOPICS.includes(event.event_type as (typeof ACCOUNT_TOPICS)[number]))@@@ -69,8 +63,8 @@ export function createAccountData(opts: {if (event.event_type === 'network.account.export_requested') {const id = String(p.export_id ?? '')if (!EXPORT_RE.test(id)) return 'ignored:payload'-- if (get.get(id)?.sent_at) return 'unchanged'-- const data = opts.identity.exportSubject(subject)++ if ((await get(id))?.sent_at) return 'unchanged'++ const data = await opts.identity.exportSubject(subject)const files = [{ name: 'characters.json', content: data.characters },...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),@@@ -82,22 +76,17 @@ export function createAccountData(opts: {? 'closed': nullif (!outcome) throw new Error(`export part refused: ${res.status}`)-- opts.db-- .prepare(-- 'INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)',-- )-- .run(-- id,-- 'export',-- subject,-- JSON.stringify({ result: outcome, characters: data.characters.length }),-- new Date().toISOString(),-- )++ await opts.db.exec(sql`++ INSERT INTO account_data_events (id, kind, subject, outcome, sent_at)++ VALUES (${id}, 'export', ${subject}, ${sql.json({ result: outcome, characters: data.characters.length })}, now())++ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject,++ outcome = excluded.outcome, sent_at = excluded.sent_at++ `)return outcome}const id = String(p.deletion_id ?? '')if (!DELETION_RE.test(id)) return 'ignored:payload'-- let rec = get.get(id)++ let rec = await get(id)let result = 'confirmed'if (!rec) {const aliases = Array.isArray(p.aliases)@@@ -105,16 +94,19 @@ export function createAccountData(opts: {: []const subjects = [subject, ...aliases]await opts.closeSessions(subjects)-- const erased = opts.identity.eraseSubjects(subjects)-- opts.db-- .prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)')-- .run(id, 'deletion', subject, JSON.stringify({ erased }))++ const erased = await opts.identity.eraseSubjects(subjects)++ await opts.db.exec(sql`++ INSERT INTO account_data_events (id, kind, subject, outcome)++ VALUES (${id}, 'deletion', ${subject}, ${sql.json({ erased })})++ ON CONFLICT (id) DO NOTHING++ `)opts.log.info('account deleted: game data erased', { ...erased })-- rec = get.get(id) as Record_++ rec = await get(id)result = 'erased'}++ if (!rec) throw new Error('deletion record disappeared')if (rec.sent_at) return 'unchanged'-- const o = JSON.parse(rec.outcome ?? '{}') as { erased?: Record<string, number> }++ const o = (rec.outcome ?? {}) as { erased?: Record<string, number> }const erased = Object.fromEntries(Object.entries(o.erased ?? {}).filter(([k]) => k !== 'structures_unowned'),)@@@ -126,7 +118,7 @@ export function createAccountData(opts: {retained,})if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`)-- markSent.run(new Date().toISOString(), id)++ await markSent(id)return result}@@@ -1,6 +1,7 @@import { createRequire } from 'node:module'--import { openSqliteStore } from '@openvibe/persistence/sqlite'--import { createEventsClient, createOutbox, type SqliteDatabase } from 'openvibe-sdk/events'++import { openTestStore } from '@openvibe/persistence/testing'++import type { Tx } from 'openvibe-sdk/db'++import { createEventsClient, createPgOutbox } from 'openvibe-sdk/events'import { createMockPlatform } from 'openvibe-sdk/testing'import { describe, expect, it } from 'vitest'import { loadPlatformConfig } from '../config.js'@@@ -28,13 +29,17 @@ const contracts = createRequire(import.meta.url)('openvibe-contracts') as Contraconst SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'const signedIn = { playerId: 'pl_1', slot: 0, name: 'Ana', subjectId: SUBJECT }const guest = { playerId: 'pl_2', slot: 0, name: 'Guest', subjectId: null }++/** The recorder only forwards the transaction handle to the sink; a capture sink ignores it. */++const ANY_TX = {} as Txfunction capture(): EventSink & { events: { event_type: string; payload: unknown }[] } {const events: { event_type: string; payload: unknown }[] = []return {enabled: true,events,-- enqueue: (e) => events.push(e as { event_type: string; payload: unknown }),++ enqueue: async (_t, e) => {++ events.push(e as { event_type: string; payload: unknown })++ },}}@@@ -116,18 +121,21 @@ describe('event envelopes', () => {})describe('GameEventRecorder', () => {-- it('diffs each save against the last persisted progress', () => {++ it('diffs each save against the last persisted progress', async () => {const sink = capture()const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)-- rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)-- rec.recordPlayersSaved([++ await rec.recordJoin(ANY_TX, signedIn, { levels: { mining: 2 }, unlocks: [] }, true)++ let commit = await rec.recordPlayersSaved(ANY_TX, [{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },-- ])()++ ])++ commit()// Saved again with no change: nothing new.-- rec.recordPlayersSaved([++ commit = await rec.recordPlayersSaved(ANY_TX, [{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },-- ])()-- rec.recordLeave(signedIn)()++ ])++ commit()++ commit = await rec.recordLeave(ANY_TX, signedIn)++ commit()expect(sink.events.map((e) => e.event_type)).toEqual(['games.player.joined','games.skill.leveled',@@@ -135,35 +143,56 @@ describe('GameEventRecorder', () => {])})-- it('does not advance the baseline when the save is not committed', () => {++ it('does not advance the baseline when the save is not committed', async () => {const sink = capture()const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)-- rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)++ await rec.recordJoin(ANY_TX, signedIn, { levels: { mining: 2 }, unlocks: [] }, true)// Rolled back: the commit callback never runs, so the next save reports it again.-- rec.recordPlayersSaved([{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } }])-- rec.recordPlayersSaved([++ await rec.recordPlayersSaved(ANY_TX, [++ { player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },++ ])++ const commit = await rec.recordPlayersSaved(ANY_TX, [{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },-- ])()++ ])++ commit()expect(sink.events.filter((e) => e.event_type === 'games.skill.leveled')).toHaveLength(2)})-- it('checkpoints world saves at most once per interval, always on shutdown', () => {++ it('checkpoints world saves at most once per interval, always on shutdown', async () => {let now = 0const sink = capture()const rec = new GameEventRecorder(sink, 'w', 60_000, () => now)now = 10_000-- rec.recordWorldSaved(5, 1, 'checkpoint')()++ let commit = await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')++ commit()now = 70_000-- rec.recordWorldSaved(2, 0, 'checkpoint')()++ commit = await rec.recordWorldSaved(ANY_TX, 2, 0, 'checkpoint')++ commit()now = 80_000-- rec.recordWorldSaved(1, 1, 'checkpoint')()-- rec.recordWorldSaved(0, 0, 'shutdown')()++ commit = await rec.recordWorldSaved(ANY_TX, 1, 1, 'checkpoint')++ commit()++ commit = await rec.recordWorldSaved(ANY_TX, 0, 0, 'shutdown')++ commit()const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')expect(saved.map((e) => e.payload)).toEqual([expect.objectContaining({ reason: 'checkpoint', entities_written: 7, players_written: 1 }),expect.objectContaining({ reason: 'shutdown', entities_written: 1, players_written: 1 }),])})++++ it('counts a rolled-back world save once when the flush is retried', async () => {++ const sink = capture()++ const rec = new GameEventRecorder(sink, 'w', 0, () => 1)++ // First attempt: the event is queued but its transaction rolls back, so the commit callback never++ // runs. The retry of the same flush must not add its rows to the accumulator a second time.++ await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')++ const commit = await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')++ commit()++ const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')++ expect(saved.at(-1)?.payload).toEqual(++ expect.objectContaining({ entities_written: 5, players_written: 1 }),++ )++ })})describe('transactional outbox → OpenVibe.Events (mock platform)', () => {@@@ -183,40 +212,35 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {eventsUrl: platform.origins.events,}const pc = createPlatformClient(cfg, platform.fetch)!-- const store = openSqliteStore(':memory:')-- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {++ const store = await openTestStore()++ const outbox = createPgOutbox(store.db, {events: createEventsClient(pc.client, { source: EVENT_SOURCE }),})-- outbox.ensureSchema()-- const sink = outboxSink(outbox)++ const sink = outboxSink(outbox, store.db)// Committed with the change it describes.-- store.transaction(() => {-- store.meta.set('env_time', '0.5')-- sink.enqueue(playerJoinedEvent(signedIn, { world: 'w', restored: false }))++ await store.transaction(async (t) => {++ await store.meta.set('env_time', 0.5)++ await sink.enqueue(t, playerJoinedEvent(signedIn, { world: 'w', restored: false }))})// Rolled back: the event never existed.-- expect(() =>-- store.transaction(() => {-- store.meta.set('env_time', '0.9')-- sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 }))++ await expect(++ store.transaction(async (t) => {++ await store.meta.set('env_time', 0.9)++ await sink.enqueue(t, playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 }))throw new Error('rollback')}),-- ).toThrow('rollback')-- // Outside a transaction the outbox refuses.-- expect(() =>-- sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 })),-- ).toThrow()++ ).rejects.toThrow('rollback')-- expect(outbox.pending()).toBe(1)++ expect(await outbox.pending()).toBe(1)const stats = await outbox.flush()expect(stats).toEqual({ sent: 1, failed: 0, rejected: 0 })expect(platform.state.events.map((e) => [e.event.event_type, e.publisher])).toEqual([['games.player.joined', 'svc:games'],])-- expect(store.meta.get('env_time')).toBe('0.5')++ expect(await store.meta.get('env_time')).toBe(0.5)await outbox.stop()-- store.close()++ await store.close()})it('keeps events queued while the grant is missing', async () => {@@@ -227,22 +251,45 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {networkUrl: platform.origins.network,eventsUrl: platform.origins.events,}-- const store = openSqliteStore(':memory:')-- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {++ const store = await openTestStore()++ const outbox = createPgOutbox(store.db, {events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {source: EVENT_SOURCE,}),})-- outbox.ensureSchema()-- store.transaction(() =>-- outboxSink(outbox).enqueue(++ await store.transaction((t) =>++ outboxSink(outbox, store.db).enqueue(++ t,worldSavedEvent('w', { reason: 'shutdown', entities: 0, players: 0, sinceMs: 0 }),),)const stats = await outbox.flush()expect(stats.sent).toBe(0)-- expect(outbox.pending() + outbox.rejected()).toBe(1)++ expect(((await outbox.pending()) ?? 0) + ((await outbox.rejected()) ?? 0)).toBe(1)++ await outbox.stop()++ await store.close()++ })++++ it('refuses to enqueue outside a transaction: an event never commits on its own connection', async () => {++ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })++ const cfg = {++ ...loadPlatformConfig({}),++ clientSecret: 's3cret',++ networkUrl: platform.origins.network,++ eventsUrl: platform.origins.events,++ }++ const store = await openTestStore()++ const outbox = createPgOutbox(store.db, {++ events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {++ source: EVENT_SOURCE,++ }),++ })++ const sink = outboxSink(outbox, store.db)++ await expect(++ sink.enqueue({} as Tx, playerJoinedEvent(signedIn, { world: 'w', restored: false })),++ ).rejects.toThrow('must run inside the transaction')++ expect(await outbox.pending()).toBe(0)await outbox.stop()-- store.close()++ await store.close()})})@@@ -23,7 +23,8 @@* events.event.publish). Events or Network being down never blocks a tick:* rows wait and are retried.*/--import type { EventInput, Outbox } from 'openvibe-sdk/events'++import type { EventInput, PgOutbox } from 'openvibe-sdk/events'++import type { Db, Tx } from 'openvibe-sdk/db'import type { SubjectRef } from 'openvibe-sdk/core'export const EVENT_SOURCE = 'games'@@@ -210,20 +211,30 @@ export function moderationEvent(}}--/** Where events go. `enqueue` must run inside the transaction making the change. */++/**++ * Where events go. `enqueue` is awaited inside the transaction making the change and is handed that++ * transaction's handle: the SDK's PostgreSQL outbox writes the row on it, so the event exists if and++ * only if the change commits. Only the relay wake is deferred.++ */export interface EventSink {readonly enabled: boolean-- enqueue(event: EventInput): void++ enqueue(t: Tx, event: EventInput): Promise<void>}--export const NO_EVENTS: EventSink = { enabled: false, enqueue: () => {} }++export const NO_EVENTS: EventSink = { enabled: false, enqueue: async () => {} }--/** An EventSink over the SDK outbox; wakes the relay right after the write. */--export function outboxSink(outbox: Outbox): EventSink {++/** An EventSink over the SDK PostgreSQL outbox; wakes the relay right after the write. */++export function outboxSink(outbox: PgOutbox, db: Db): EventSink {return {enabled: true,-- enqueue(event) {-- outbox.enqueue(event)++ async enqueue(t, event) {++ // The old SQLite outbox refused an enqueue outside a transaction; the PG port takes the tx handle++ // but a Db also satisfies the type, so nothing else would stop an event committing on its own++ // connection. Refuse it here too.++ if (!db.inTransaction()) {++ throw new Error('outbox.enqueue() must run inside the transaction that makes the change')++ }++ await outbox.enqueue(t, event)setImmediate(() => outbox.kick())},}@@@ -238,7 +249,7 @@ interface Tracked {/*** Per-session bookkeeping the game server drives: the progression baseline* each save is compared with, join times, and the world-save accumulator.-- * All `record*` methods run inside the caller's store transaction and return++ * All `record*` methods are handed the caller's transaction handle and return* a callback to run once it has committed.*/export class GameEventRecorder {@@@ -263,43 +274,51 @@ export class GameEventRecorder {}/** A character joined; `progress` is what the database holds for it. */-- recordJoin(player: EventPlayer, progress: ProgressSnapshot, restored: boolean): void {++ async recordJoin(++ t: Tx,++ player: EventPlayer,++ progress: ProgressSnapshot,++ restored: boolean,++ ): Promise<void> {this.tracked.set(player.playerId, {player,progress: cloneProgress(progress),joinedAt: this.now(),})-- this.sink.enqueue(playerJoinedEvent(player, { world: this.world, restored }))++ await this.sink.enqueue(t, playerJoinedEvent(player, { world: this.world, restored }))}/*** Players were written: emits the progression they newly persisted.* Returns the commit callback that advances their baselines.*/-- recordPlayersSaved(saved: { player: EventPlayer; progress: ProgressSnapshot }[]): () => void {++ async recordPlayersSaved(++ t: Tx,++ saved: { player: EventPlayer; progress: ProgressSnapshot }[],++ ): Promise<() => void> {const advance: { id: string; progress: ProgressSnapshot }[] = []for (const { player, progress } of saved) {-- const t = this.tracked.get(player.playerId)-- if (t) {-- for (const ev of progressEvents(player, t.progress, progress, this.world)) {-- this.sink.enqueue(ev)++ const tr = this.tracked.get(player.playerId)++ if (tr) {++ for (const ev of progressEvents(player, tr.progress, progress, this.world)) {++ await this.sink.enqueue(t, ev)}}advance.push({ id: player.playerId, progress: cloneProgress(progress) })}return () => {for (const a of advance) {-- const t = this.tracked.get(a.id)-- if (t) t.progress = a.progress++ const tr = this.tracked.get(a.id)++ if (tr) tr.progress = a.progress}}}/** The character left; its final save is part of the same transaction. */-- recordLeave(player: EventPlayer): () => void {-- const t = this.tracked.get(player.playerId)-- const sessionSeconds = t ? (this.now() - t.joinedAt) / 1000 : 0-- this.sink.enqueue(playerLeftEvent(player, { world: this.world, sessionSeconds }))++ async recordLeave(t: Tx, player: EventPlayer): Promise<() => void> {++ const tr = this.tracked.get(player.playerId)++ const sessionSeconds = tr ? (this.now() - tr.joinedAt) / 1000 : 0++ await this.sink.enqueue(t, playerLeftEvent(player, { world: this.world, sessionSeconds }))return () => {this.tracked.delete(player.playerId)}@@@ -310,24 +329,32 @@ export class GameEventRecorder {* `games.world.saved` checkpoint at most once per interval (and always on* shutdown) with the totals since the previous one.*/-- recordWorldSaved(++ async recordWorldSaved(++ t: Tx,entities: number,players: number,reason: 'checkpoint' | 'shutdown',-- ): () => void {-- this.pendingEntities += entities-- this.pendingPlayers += players++ ): Promise<() => void> {const nowMs = this.now()++ // The accumulator holds only committed saves. This flush's rows join it at commit, never before,++ // so a failed (and retried) save is counted once and a rollback leaves nothing behind.++ const totalEntities = this.pendingEntities + entities++ const totalPlayers = this.pendingPlayers + playersconst due =reason === 'shutdown' ||-- (nowMs - this.lastSavedEventMs >= this.checkpointEveryMs &&-- this.pendingEntities + this.pendingPlayers > 0)-- if (!due) return () => {}-- this.sink.enqueue(++ (nowMs - this.lastSavedEventMs >= this.checkpointEveryMs && totalEntities + totalPlayers > 0)++ if (!due) {++ return () => {++ this.pendingEntities = totalEntities++ this.pendingPlayers = totalPlayers++ }++ }++ await this.sink.enqueue(++ t,worldSavedEvent(this.world, {reason,-- entities: this.pendingEntities,-- players: this.pendingPlayers,++ entities: totalEntities,++ players: totalPlayers,sinceMs: this.sinceMs,}),)@@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'--import { openSqliteStore } from '@openvibe/persistence/sqlite'++import type { PersistenceStore } from '@openvibe/persistence'++import { openTestStore } from '@openvibe/persistence/testing'++import type { Logger } from '@openvibe/shared'import { createConsoleLogger } from '@openvibe/shared'++import type { OpenVibeClient } from 'openvibe-sdk/core'import { describe, expect, it } from 'vitest'import { loadPlatformConfig } from '../config.js'import { MediaMirror } from './mediaMirror.js'@@@ -94,14 +97,14 @@ function fakePlatform(opts: { failPuts?: number; completeStatus?: number } = {})return { fetchImpl, objects, calls }}--function setup(platform: ReturnType<typeof fakePlatform>) {++async function setup(platform: ReturnType<typeof fakePlatform>) {const dir = mkdtempSync(join(tmpdir(), 'openvibe-mirror-'))const assetsDir = join(dir, 'map-assets')mkdirSync(assetsDir)const hash = `sha256-${createHash('sha256').update(PNG).digest('hex')}`const fileName = `${hash}.png`writeFileSync(join(assetsDir, fileName), PNG)-- const store = openSqliteStore(':memory:')++ const store = await openTestStore()let now = 1_000const pc = createPlatformClient({@@@ -129,14 +132,30 @@ function setup(platform: ReturnType<typeof fakePlatform>) {}}++/**++ * MediaMirror.enqueue is fire-and-forget (void); poll until the queue row it writes has landed so a++ * following runOnce is deterministic.++ */++async function queued(++ store: Awaited<ReturnType<typeof openTestStore>>,++ hash: string,++): Promise<void> {++ for (let i = 0; i < 500; i++) {++ if (await store.mediaMirrors.get(hash)) return++ await new Promise((resolve) => setTimeout(resolve, 2))++ }++ throw new Error(`asset ${hash} was not queued`)++}++describe('map assets → Media objects (games service token)', () => {it('mirrors a stored asset with init, content and a hash-verified complete', async () => {const platform = fakePlatform()-- const { store, mirror, hash, asset } = setup(platform)++ const { store, mirror, hash, asset } = await setup(platform)mirror.enqueue(asset)++ await queued(store, hash)mirror.stop()await mirror.runOnce()-- const row = store.mediaMirrors.get(hash)!++ const row = (await store.mediaMirrors.get(hash))!expect(row).toMatchObject({ status: 'mirrored', attempts: 1 })expect(row.mediaId).toMatch(/^med_/)expect(row.publicUrl).toBe(`https://openvibe.media/o/${row.mediaId}`)@@@ -151,27 +170,28 @@ describe('map assets → Media objects (games service token)', () => {it('backfills assets already on disk', async () => {const platform = fakePlatform()-- const { store, mirror, hash } = setup(platform)++ const { store, mirror, hash } = await setup(platform)expect(await mirror.backfill()).toBe(1)expect(await mirror.backfill()).toBe(0)await mirror.runOnce()-- expect(store.mediaMirrors.get(hash)?.status).toBe('mirrored')++ expect((await store.mediaMirrors.get(hash))?.status).toBe('mirrored')})it('resumes the same object after a failure instead of creating another', async () => {const platform = fakePlatform({ failPuts: 1 })-- const { store, mirror, hash, asset, advance } = setup(platform)++ const { store, mirror, hash, asset, advance } = await setup(platform)mirror.enqueue(asset)++ await queued(store, hash)mirror.stop()await mirror.runOnce()-- const failed = store.mediaMirrors.get(hash)!++ const failed = (await store.mediaMirrors.get(hash))!expect(failed).toMatchObject({ status: 'pending', attempts: 1 })expect(failed.lastError).toMatch(/503|unavailable/)await mirror.runOnce() // not due yet-- expect(store.mediaMirrors.get(hash)?.attempts).toBe(1)++ expect((await store.mediaMirrors.get(hash))?.attempts).toBe(1)advance(60_000)await mirror.runOnce()-- expect(store.mediaMirrors.get(hash)).toMatchObject({++ expect(await store.mediaMirrors.get(hash)).toMatchObject({status: 'mirrored',mediaId: failed.mediaId,})@@@ -180,10 +200,64 @@ describe('map assets → Media objects (games service token)', () => {it('gives up on refusals retrying cannot fix', async () => {const platform = fakePlatform({ completeStatus: 422 })-- const { store, mirror, hash, asset } = setup(platform)++ const { store, mirror, hash, asset } = await setup(platform)mirror.enqueue(asset)++ await queued(store, hash)mirror.stop()await mirror.runOnce()-- expect(store.mediaMirrors.get(hash)?.status).toBe('failed')++ expect((await store.mediaMirrors.get(hash))?.status).toBe('failed')++ })++++ it('a scheduled pass whose failure write rejects is logged, not an unhandled rejection', async () => {++ // A store whose only due row cannot be read locally, and whose markFailed write then rejects:++ // pass() rejects (fail() awaits markFailed too).++ const row = {++ assetHash: `sha256-${'a'.repeat(64)}`,++ fileName: `sha256-${'a'.repeat(64)}.png`,++ mime: 'image/png',++ attempts: 0,++ }++ const store = {++ mediaMirrors: {++ due: async () => [row],++ markFailed: async () => {++ throw new Error('persistence down')++ },++ },++ } as unknown as PersistenceStore++ const warnings: string[] = []++ const quiet = {++ debug: () => {},++ info: () => {},++ warn: (message: string) => warnings.push(message),++ error: () => {},++ } as unknown as Logger++ const mirror = new MediaMirror({++ client: {} as OpenVibeClient,++ store,++ namespace: 'games',++ assetsDir: mkdtempSync(join(tmpdir(), 'openvibe-mirror-fail-')),++ log: quiet,++ intervalMs: 60_000,++ })++ // Direct callers still see the rejection: runOnce's contract is unchanged.++ await expect(mirror.runOnce()).rejects.toThrow('persistence down')++++ // The scheduled path must swallow it (a warning), or the process sees an unhandled rejection.++ const unhandled: unknown[] = []++ const onUnhandled = (err: unknown) => unhandled.push(err)++ process.on('unhandledRejection', onUnhandled)++ try {++ mirror.kick()++ for (let i = 0; i < 200 && !warnings.includes('media mirror pass failed'); i++) {++ await new Promise((resolve) => setTimeout(resolve, 5))++ }++ await new Promise((resolve) => setTimeout(resolve, 20))++ } finally {++ process.off('unhandledRejection', onUnhandled)++ await mirror.stop()++ }++ expect(warnings).toContain('media mirror pass failed')++ expect(unhandled).toEqual([])})})@@@ -69,15 +69,21 @@ export class MediaMirror {this.now = opts.now ?? Date.now}-- /** Queues one stored asset (idempotent) and wakes the worker. */++ /** Queues one stored asset (idempotent) and wakes the worker. Fire-and-forget (off the tick). */enqueue(asset: { hash: string; url: string; bytes: number; mime: string }): void {const fileName = asset.url.split('/').pop() ?? ''if (!CONTENT_ADDRESSED.test(fileName)) return-- const added = this.opts.store.mediaMirrors.enqueue(-- { assetHash: asset.hash, fileName, mime: asset.mime, bytes: asset.bytes },-- this.now(),-- )-- if (added) this.kick()++ void this.opts.store.mediaMirrors++ .enqueue(++ { assetHash: asset.hash, fileName, mime: asset.mime, bytes: asset.bytes },++ this.now(),++ )++ .then((added) => {++ if (added) this.kick()++ })++ .catch((err: unknown) => {++ this.opts.log.warn('media mirror enqueue failed', { error: String(err) })++ })}/** Queues every content-addressed asset already on disk (a no-op for known ones). */@@@ -93,7 +99,7 @@ export class MediaMirror {const m = CONTENT_ADDRESSED.exec(name)if (!m) continueconst bytes = (await readFile(join(this.opts.assetsDir, name))).length-- const queued = this.opts.store.mediaMirrors.enqueue(++ const queued = await this.opts.store.mediaMirrors.enqueue({ assetHash: m[1] ?? '', fileName: name, mime: MIME_BY_EXT[m[2] ?? ''] ?? '', bytes },this.now(),)@@@ -134,17 +140,23 @@ export class MediaMirror {if (this.timer) clearTimeout(this.timer)this.timer = setTimeout(() => {this.timer = null-- void this.runOnce().finally(() => this.schedule(this.opts.intervalMs ?? 60_000))++ // The scheduled path must never surface an unhandled rejection: a rejected pass() (e.g. the++ // markFailed write failing) is logged, then the next pass is scheduled as usual.++ void this.runOnce()++ .finally(() => this.schedule(this.opts.intervalMs ?? 60_000))++ .catch((err: unknown) => {++ this.opts.log.warn('media mirror pass failed', { error: String(err) })++ })}, ms)this.timer.unref()}private async pass(): Promise<void> {-- for (const row of this.opts.store.mediaMirrors.due(this.now(), 20)) {++ for (const row of await this.opts.store.mediaMirrors.due(this.now(), 20)) {try {await this.mirror(row)} catch (err) {-- this.fail(row, err as HttpError)++ await this.fail(row, err as HttpError)}}}@@@ -159,7 +171,7 @@ export class MediaMirror {try {bytes = await readFile(join(this.opts.assetsDir, row.fileName))} catch {-- store.mediaMirrors.markFailed(++ await store.mediaMirrors.markFailed(row.assetHash,'local file missing',this.now(),@@@ -187,7 +199,7 @@ export class MediaMirror {idempotencyKey: false,})id = init.id-- store.mediaMirrors.noteObject(row.assetHash, id, this.now())++ await store.mediaMirrors.noteObject(row.assetHash, id, this.now())}try {await client.request({@@@ -209,17 +221,22 @@ export class MediaMirror {json: { content_hash: sha256 },idempotent: true,})-- store.mediaMirrors.markMirrored(row.assetHash, done.id, done.public_url ?? null, this.now())++ await store.mediaMirrors.markMirrored(++ row.assetHash,++ done.id,++ done.public_url ?? null,++ this.now(),++ )this.opts.log.info('map asset mirrored to media', { asset: row.assetHash, media: done.id })}-- private fail(row: MediaMirrorDto, err: HttpError): void {++ private async fail(row: MediaMirrorDto, err: HttpError): Promise<void> {const status = err.status ?? 0const attempts = row.attempts + 1const terminal = TERMINAL_STATUS.has(status) || attempts >= MAX_ATTEMPTSconst wait = BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)] ?? 60_000const message = `${err.code ?? `http.${status}`}: ${err.message ?? 'failed'}`-- this.opts.store.mediaMirrors.markFailed(++ await this.opts.store.mediaMirrors.markFailed(row.assetHash,message,this.now() + wait,@@@ -1,6 +1,8 @@++import { EventEmitter } from 'node:events'import { createServer, type Server } from 'node:http'import type { AddressInfo } from 'node:net'import { createRequire } from 'node:module'++import { openTestDb } from '@openvibe/persistence/testing'import { createConsoleLogger } from '@openvibe/shared'import { afterAll, beforeAll, describe, expect, it } from 'vitest'import {@@@ -16,7 +18,6 @@ import {* person's sessions are closed through onRevoked; the boot subscription is created once.*/const req = createRequire(import.meta.url)--const Database = req('better-sqlite3') as new (path: string) => unknownconst { signDeliveryHeaders } = req('openvibe-sdk/events') as {signDeliveryHeaders(body: string, secret: string): Record<string, string>}@@@ -28,17 +29,17 @@ const mergedCalls: [string, string, string][] = []const accountCalls: string[] = []const modGrantCalls: [string, string, string][] = []const events = createRevocationEvents({-- db: new Database(':memory:'),++ db: await openTestDb(),secrets: [SECRET],onRevoked: (s, ms) => {closedFor.push([s, ms])return 2},-- onMerged: (from, into, id) => {++ onMerged: async (from, into, id) => {mergedCalls.push([from, into, id])return { moved: 2, kept: 1, already: mergedCalls.length > 1 }},-- onModGrants: (p) => {++ onModGrants: async (p) => {modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])return 'mod:enabled'},@@@ -50,6 +51,7 @@ const events = createRevocationEvents({},log,})++await events.store.load()let server: Serverlet url: string@@@ -149,6 +151,54 @@ describe('revocation events', () => {])})++ it('a response write that throws on the success path is caught, not an unhandled rejection', async () => {++ // A response whose first end() throws (the socket closed under us) and whose second succeeds.++ let ends = 0++ const res = {++ writableEnded: false,++ statusCode: 0,++ writeHead(status: number) {++ res.statusCode = status++ return res++ },++ end() {++ ends++++ if (ends === 1) throw new Error('write after end')++ res.writableEnded = true++ return res++ },++ }++ const body = JSON.stringify({++ event: envelope({++ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8',++ event_type: 'network.account.export_requested',++ payload: { export_id: 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2F9', subject: SUBJECT },++ }),++ seq: 1,++ })++ const req = Object.assign(new EventEmitter(), {++ url: '/internal/events',++ method: 'POST',++ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) },++ })++ const unhandled: unknown[] = []++ const onUnhandled = (err: unknown) => unhandled.push(err)++ process.on('unhandledRejection', onUnhandled)++ try {++ if (!events.handle(req as never, res as never)) throw new Error('not handled')++ req.emit('data', Buffer.from(body))++ req.emit('end')++ for (let i = 0; i < 100 && !res.writableEnded; i++) {++ await new Promise((resolve) => setTimeout(resolve, 5))++ }++ await new Promise((resolve) => setTimeout(resolve, 20))++ } finally {++ process.off('unhandledRejection', onUnhandled)++ }++ expect(unhandled).toEqual([])++ expect(res.statusCode).toBe(500)++ })++it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'const changed = (over: Record<string, unknown> = {}) =>@@@ -18,12 +18,15 @@*/import type { IncomingMessage, ServerResponse } from 'node:http'import { createRequire } from 'node:module'++import type { Db } from 'openvibe-sdk/db'import type { Logger } from '@openvibe/shared'const req = createRequire(import.meta.url)interface RevocationStore {-- apply(event: unknown): string++ /** Reads every cutoff into memory at boot (few people have one); call before serving. */++ load(): Promise<number>++ apply(event: unknown): Promise<string>isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): booleancutoffFor(subject: string): number}@@@ -50,8 +53,8 @@ type ParseDelivery = (}} | null--const { createRevocationStore } = req('openvibe-sdk/auth') as {-- createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore++const { createPgRevocationStore } = req('openvibe-sdk/auth') as {++ createPgRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore}const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }@@@ -78,7 +81,7 @@ export interface RevocationEvents {}export function createRevocationEvents(opts: {-- db: unknown++ db: Dbsecrets: string[]onRevoked: (subjectId: string, validAfterMs: number) => number/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */@@@ -86,7 +89,7 @@ export function createRevocationEvents(opts: {from: string,into: string,mergeId: string,-- ) => { moved: number; kept: number; already: boolean }++ ) => Promise<{ moved: number; kept: number; already: boolean }>/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */onAccountEvent?: (event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,@@@ -98,10 +101,10 @@ export function createRevocationEvents(opts: {status: stringapproved: string[]revision: number-- }) => string++ }) => Promise<string>log: Logger}): RevocationEvents {-- const store = createRevocationStore(opts.db, { table: 'token_revocations' })++ const store = createPgRevocationStore(opts.db, { table: 'token_revocations' })const stats = { received: 0, revoked: 0, closed: 0, refused: 0, ignored: 0 }const send = (res: ServerResponse, status: number, body: unknown): void => {res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })@@@ -133,7 +136,7 @@ export function createRevocationEvents(opts: {if (size > MAX_BODY) req.destroy()else chunks.push(c)})-- req.on('end', () => {++ req.on('end', async () => {const raw = Buffer.concat(chunks)let delivery: ReturnType<ParseDelivery> = nullfor (const s of opts.secrets) {@@@ -147,93 +150,101 @@ export function createRevocationEvents(opts: {}stats.received++const event = delivery.event-- if (-- event.event_type === 'network.account.export_requested' ||-- event.event_type === 'network.account.deleted'-- ) {-- if (!opts.onAccountEvent) {-- stats.ignored++-- send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })-- return-- }-- opts.onAccountEvent(event).then(-- (outcome) => {++ try {++ if (++ event.event_type === 'network.account.export_requested' ||++ event.event_type === 'network.account.deleted'++ ) {++ if (!opts.onAccountEvent) {++ stats.ignored++++ send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })++ return++ }++ // Awaited inside the outer try: a throw from send() (e.g. a closed socket) is caught here++ // too, not just a rejection from onAccountEvent.++ try {++ const outcome = await opts.onAccountEvent(event)if (outcome.startsWith('ignored')) stats.ignored++send(res, 200, { event_id: event.event_id ?? null, outcome })-- },-- (err: unknown) => {++ } catch (err) {opts.log.warn('account event failed (Events retries)', {type: event.event_type,error: String((err as Error)?.message ?? err),})-- send(res, 500, { error: 'not applied' })-- },-- )-- return-- }-- if (event.event_type === MOD_GRANTS_TOPIC) {-- const p = (event.payload ?? {}) as {-- mod_id?: unknown-- owner?: unknown-- status?: unknown-- approved?: unknown-- revision?: unknown++ if (!res.writableEnded) send(res, 500, { error: 'not applied' })++ }++ return}-- let outcome: string-- if (event.source !== 'network') outcome = 'ignored:source'-- else if (!opts.onModGrants) outcome = 'ignored:no_handler'-- else if (-- typeof p.mod_id !== 'string' ||-- !Array.isArray(p.approved) ||-- typeof p.status !== 'string'-- )-- outcome = 'ignored:payload'-- else if (p.owner !== 'games') outcome = 'ignored:owner'-- else-- outcome = opts.onModGrants({-- mod_id: p.mod_id,-- owner: p.owner,-- status: p.status,-- approved: p.approved.map(String),-- revision: Number(p.revision) || 0,-- })-- if (outcome.startsWith('ignored')) stats.ignored++-- send(res, 200, { event_id: event.event_id ?? null, outcome })-- return-- }-- if (event.event_type === MERGE_TOPIC) {-- const p = event.payload ?? {}-- let outcome = 'merged'-- if (event.source !== 'network') outcome = 'ignored:source'-- else if (!opts.onMerged) outcome = 'ignored:no_handler'-- else if (-- !MERGE_RE.test(String(p.merge_id ?? '')) ||-- !SUBJECT_RE.test(String(p.from ?? '')) ||-- !SUBJECT_RE.test(String(p.into ?? '')) ||-- p.from === p.into-- )-- outcome = 'ignored:payload'-- else {-- const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))-- outcome = r.already ? 'unchanged' : 'merged'-- if (!r.already)-- opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })++ if (event.event_type === MOD_GRANTS_TOPIC) {++ const p = (event.payload ?? {}) as {++ mod_id?: unknown++ owner?: unknown++ status?: unknown++ approved?: unknown++ revision?: unknown++ }++ let outcome: string++ if (event.source !== 'network') outcome = 'ignored:source'++ else if (!opts.onModGrants) outcome = 'ignored:no_handler'++ else if (++ typeof p.mod_id !== 'string' ||++ !Array.isArray(p.approved) ||++ typeof p.status !== 'string'++ )++ outcome = 'ignored:payload'++ else if (p.owner !== 'games') outcome = 'ignored:owner'++ else++ outcome = await opts.onModGrants({++ mod_id: p.mod_id,++ owner: p.owner,++ status: p.status,++ approved: p.approved.map(String),++ revision: Number(p.revision) || 0,++ })++ if (outcome.startsWith('ignored')) stats.ignored++++ send(res, 200, { event_id: event.event_id ?? null, outcome })++ return}-- if (outcome.startsWith('ignored')) stats.ignored++-- send(res, 200, { event_id: event.event_id ?? null, outcome })-- return++ if (event.event_type === MERGE_TOPIC) {++ const p = event.payload ?? {}++ let outcome = 'merged'++ if (event.source !== 'network') outcome = 'ignored:source'++ else if (!opts.onMerged) outcome = 'ignored:no_handler'++ else if (++ !MERGE_RE.test(String(p.merge_id ?? '')) ||++ !SUBJECT_RE.test(String(p.from ?? '')) ||++ !SUBJECT_RE.test(String(p.into ?? '')) ||++ p.from === p.into++ )++ outcome = 'ignored:payload'++ else {++ const r = await opts.onMerged(String(p.from), String(p.into), String(p.merge_id))++ outcome = r.already ? 'unchanged' : 'merged'++ if (!r.already)++ opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })++ }++ if (outcome.startsWith('ignored')) stats.ignored++++ send(res, 200, { event_id: event.event_id ?? null, outcome })++ return++ }++ const outcome = await store.apply(event)++ let closed = 0++ if (outcome === 'revoked') {++ stats.revoked++++ const sub = event.payload?.subject++ const subject = (typeof sub === 'object' && sub ? sub.id : undefined) ?? ''++ closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))++ stats.closed += closed++ if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })++ } else if (outcome.startsWith('ignored')) stats.ignored++++ send(res, 200, { event_id: event.event_id ?? null, outcome, closed })++ } catch (err) {++ opts.log.warn('event delivery failed (Events retries)', {++ type: event.event_type,++ error: String((err as Error)?.message ?? err),++ })++ if (!res.writableEnded) send(res, 500, { error: 'not applied' })}-- const outcome = store.apply(event)-- let closed = 0-- if (outcome === 'revoked') {-- stats.revoked++-- const sub = event.payload?.subject-- const subject = (typeof sub === 'object' && sub ? sub.id : undefined) ?? ''-- closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))-- stats.closed += closed-- if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })-- } else if (outcome.startsWith('ignored')) stats.ignored++-- send(res, 200, { event_id: event.event_id ?? null, outcome, closed })})return true}@@@ -17,6 +17,8 @@ import { CLOSE_RESTART, DEADLINE_MS } from './net/gracefulStop.js'*/const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')++/** The real migrations, so the spawned server boots its embedded PGlite database. */++const MIGRATIONS_DIR = join(SERVER_DIR, '..', '..', 'packages', 'persistence', 'migrations')const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))function freePort(): Promise<number> {@@@ -71,7 +73,11 @@ describe('SIGTERM', () => {NODE_ENV: 'test',PORT: String(port),HOST: '127.0.0.1',-- DB_PATH: join(dir, 'world.db'),++ // Embedded PGlite (no DATABASE_URL), migrated from the real migrations directory.++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',++ DATABASE_DIR: join(dir, 'db'),++ GAMES_MIGRATIONS_DIR: MIGRATIONS_DIR,MAP_PATH: join(dir, 'map.json'),STATIC_DIR: '',// The platform side on, pointed at nothing: the outbox exists and has to be stopped.@@@ -17,7 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.tsEnvironment=NODE_ENV=productionEnvironment=PORT=8000Environment=HOST=127.0.0.1--Environment=DB_PATH=/opt/openvibe.games/data/world.db++Environment=GAMES_PLACE_ID=scraplandia++Environment=GAMES_MIGRATIONS_DIR=/opt/openvibe.games/packages/persistence/migrations++# DATABASE_URL (pooled), DATABASE_DIRECT_URL (owner role, for migrations) and VALKEY_URL live in the++# env file (names only; they carry credentials).Environment=MAP_PATH=/opt/openvibe.games/data/map.jsonEnvironment=STATIC_DIR=/opt/openvibe.games/apps/client/distEnvironment=SELF_URL=https://openvibe.games@@@ -25,7 +28,7 @@ Environment=PLAY_URL=https://play.openvibe.gamesEnvironmentFile=/etc/openvibe/games.envRestart=alwaysRestartSec=3--# The world flushes to SQLite on SIGTERM — give it time to finish.++# The world drains its write-behind queue to PostgreSQL on SIGTERM — give it time to finish.TimeoutStopSec=30KillSignal=SIGTERM@@@ -10,7 +10,7 @@## ovhost does the production procedure (pull, pnpm install --frozen-lockfile, pnpm build, restart# openvibe-games) with git as the checkout owner instead of sudo git; every workspace package's--# dependencies checked and better-sqlite3 loaded under this Node before the restart; /api/ready polled;++# dependencies checked before the restart (the server applies any new migration at boot); /api/ready polled;# on failure the checkout restored, reinstalled# and rebuilt (the build writes the served client) and the unit restarted again (exit 3). Players online are# reported and reconnect (drain policy report); --wait-idle holds the restart until nobody plays. Do not pull@@@ -402,8 +402,8 @@ export const ITEMS: ItemDef[] = [placeable: { maxRange: 3.5, snapStep: 0.35 },},{-- // Id (and workstation kind) 'campfire' is persisted in world.db entity-- // defIds and player inventories — the Scraplandia rename is display-only.++ // Id (and workstation kind) 'campfire' is persisted in entity defIds++ // and character inventories — the Scraplandia rename is display-only.id: 'campfire',name: 'Burn Barrel',description: 'A salvaged drum with a cook grate. Light it, cook food, stay warm.',nnew file mode 100644@@@ -0,0 +1,189 @@++-- phase: expand++-- OpenVibe.Games on PostgreSQL 18 (ADR-0007 decision 5): the schema from scratch. No SQLite history, no++-- data migration, no schema_version row. Text columns are COLLATE "C" so they compare the way the old++-- SQLite keys did; epoch-millisecond timestamps are bigint (the SDK returns int8 as Number); JSON is jsonb++-- and round-trips as objects. The event outbox and the token-revocation table are exactly the DDL++-- openvibe-sdk/events outboxSchema() and openvibe-sdk/auth revocationSchema() expect, so the platform++-- adapters need no ensureSchema() at boot.++++-- A place is a map plus its persistent state (ADR-0007 decision 4). M1 runs one instance per place; the++-- server upserts its configured place (GAMES_PLACE_ID, default 'scraplandia') before any world write.++CREATE TABLE places (++ id text COLLATE "C" PRIMARY KEY,++ name text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now()++);++++-- Per-place key/value state: env_time, env_weather, market_<id>, world_seeded, world_id.++CREATE TABLE world_meta (++ place_id text NOT NULL REFERENCES places(id),++ key text COLLATE "C" NOT NULL,++ value jsonb NOT NULL,++ PRIMARY KEY (place_id, key)++);++++CREATE TABLE world_entities (++ place_id text NOT NULL REFERENCES places(id),++ id text COLLATE "C" NOT NULL,++ kind text NOT NULL,++ def_id text NOT NULL,++ owner_id text,++ pos_x double precision NOT NULL,++ pos_y double precision NOT NULL,++ pos_z double precision NOT NULL,++ rot_x double precision NOT NULL,++ rot_y double precision NOT NULL,++ rot_z double precision NOT NULL,++ rot_w double precision NOT NULL,++ motion text NOT NULL,++ state jsonb,++ updated_at bigint NOT NULL,++ PRIMARY KEY (place_id, id)++);++CREATE INDEX world_entities_kind ON world_entities (place_id, kind);++CREATE INDEX world_entities_owner ON world_entities (owner_id) WHERE owner_id IS NOT NULL;++++CREATE TABLE world_constraints (++ place_id text NOT NULL REFERENCES places(id),++ id text COLLATE "C" NOT NULL,++ type text NOT NULL,++ entity_a text NOT NULL,++ entity_b text NOT NULL,++ params jsonb,++ updated_at bigint NOT NULL,++ PRIMARY KEY (place_id, id)++);++++-- Characters (the old `players`). account_key is the canonical subject (usr_/gst_) for Network accounts++-- and 'guest:' + sha256hex(guest token) for local guests; the raw guest token is never stored. A lookup by++-- token hashes first (accountKey()).++CREATE TABLE characters (++ id text COLLATE "C" PRIMARY KEY,++ account_key text COLLATE "C" NOT NULL,++ slot integer NOT NULL,++ subject_id text,++ name text NOT NULL,++ place_id text,++ pos_x double precision NOT NULL,++ pos_y double precision NOT NULL,++ pos_z double precision NOT NULL,++ yaw double precision NOT NULL,++ inventory jsonb NOT NULL,++ skills jsonb NOT NULL DEFAULT '{}',++ friends jsonb NOT NULL DEFAULT '[]',++ appearance jsonb,++ stats jsonb,++ armor jsonb,++ reputation jsonb NOT NULL DEFAULT '{}',++ unlocks jsonb NOT NULL DEFAULT '[]',++ active_job text,++ updated_at bigint NOT NULL++);++CREATE UNIQUE INDEX characters_account_slot ON characters (account_key, slot);++CREATE INDEX characters_subject ON characters (subject_id);++++CREATE TABLE mods (++ id text COLLATE "C" PRIMARY KEY,++ name text NOT NULL,++ version text NOT NULL,++ target text NOT NULL,++ runtime text NOT NULL,++ manifest jsonb NOT NULL,++ pack jsonb NOT NULL,++ trust_tier text NOT NULL,++ status text NOT NULL,++ installed_by text NOT NULL,++ installed_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++CREATE TABLE mod_grants (++ mod_id text NOT NULL,++ capability text NOT NULL,++ granted_by text NOT NULL,++ granted_at bigint NOT NULL,++ revoked_at bigint,++ revoked_by text,++ PRIMARY KEY (mod_id, capability)++);++++CREATE TABLE mod_placements (++ mod_id text NOT NULL,++ placement_key text NOT NULL,++ entity_id text,++ at bigint NOT NULL,++ PRIMARY KEY (mod_id, placement_key)++);++++CREATE TABLE mod_audit (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ mod_id text NOT NULL,++ action text NOT NULL,++ capability text,++ actor text NOT NULL,++ detail jsonb,++ at bigint NOT NULL++);++CREATE INDEX mod_audit_mod ON mod_audit (mod_id, id);++++CREATE TABLE media_mirrors (++ asset_hash text COLLATE "C" PRIMARY KEY,++ file_name text NOT NULL,++ mime text NOT NULL,++ bytes integer NOT NULL,++ status text NOT NULL,++ media_id text,++ public_url text,++ attempts integer NOT NULL DEFAULT 0,++ last_error text,++ next_attempt_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX media_mirrors_due ON media_mirrors (status, next_attempt_at);++++-- The once-only ledger guest conversion and account merge still use (adoption_key is the guest hash or++-- 'merge:<mergeId>'); it makes a redelivered event a no-op.++CREATE TABLE identity_adoptions (++ adoption_key text COLLATE "C" PRIMARY KEY,++ subject_id text NOT NULL,++ source text NOT NULL,++ moved integer NOT NULL DEFAULT 0,++ conflicts integer NOT NULL DEFAULT 0,++ adopted_at bigint NOT NULL++);++++-- Account export and deletion (ADR-033): one row per export/deletion network event, kept by the adapter.++CREATE TABLE account_data_events (++ id text COLLATE "C" PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ sent_at timestamptz,++ applied_at timestamptz NOT NULL DEFAULT now()++);++++-- openvibe-sdk/events createPgOutbox: enqueue runs inside the caller's transaction, the relay claims due++-- rows with a lease.++CREATE TABLE event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;++++-- openvibe-sdk/auth createPgRevocationStore: sign-out everywhere cutoffs, only ever moving forward.++CREATE TABLE token_revocations (++ subject_id text COLLATE "C" PRIMARY KEY,++ valid_after_ms bigint NOT NULL,++ reason text,++ updated_at bigint NOT NULL++);@@@ -5,18 +5,16 @@"type": "module","exports": {".": "./src/index.ts",-- "./sqlite": "./src/sqlite/index.ts"++ "./testing": "./src/testing.ts"},"scripts": {"build": "tsc --build"},"dependencies": {++ "@electric-sql/pglite": "^0.5.8","@openvibe/gameplay": "workspace:*",++ "@openvibe/protocol": "workspace:*","@openvibe/shared": "workspace:*",-- "better-sqlite3": "^11.9.1",-- "@openvibe/protocol": "workspace:*"-- },-- "devDependencies": {-- "@types/better-sqlite3": "^7.6.12"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0"}}nnew file mode 100644@@@ -0,0 +1,22 @@++import { createHash } from 'node:crypto'++import { describe, expect, it } from 'vitest'++import { accountKey } from './accountKey.js'++++describe('accountKey', () => {++ it('passes a canonical subject through unchanged', () => {++ expect(accountKey('usr_01J0000000000000000000000A')).toBe('usr_01J0000000000000000000000A')++ expect(accountKey('gst_01J0000000000000000000000A')).toBe('gst_01J0000000000000000000000A')++ })++++ it('hashes a guest token, never storing it', () => {++ const token = 'guestbrowsertoken0123456789abcdef'++ const key = accountKey(token)++ expect(key).toBe(`guest:${createHash('sha256').update(token).digest('hex')}`)++ expect(key).not.toContain(token)++ })++++ it('is idempotent: an existing account key passes through', () => {++ const hashed = accountKey('guestbrowsertoken0123456789abcdef')++ expect(accountKey(hashed)).toBe(hashed)++ })++})nnew file mode 100644@@@ -0,0 +1,18 @@++import { createHash } from 'node:crypto'++++/**++ * The canonical account key stored in `characters.account_key` (ADR-0006).++ *++ * Signed-in accounts use their canonical openvibe.network subject (`usr_…`, or `gst_…` for a Network++ * guest), which is already a key. Local guests (ADR-0004) use `guest:` + sha256hex(their browser++ * token) — the raw token is never stored. The function is idempotent: a value that is already an++ * account key passes through unchanged, so a DTO read back from the database can be written again++ * without re-hashing.++ */++const SUBJECT = /^(usr|gst)_/++const GUEST_KEY = /^guest:/++++export function accountKey(token: string): string {++ if (SUBJECT.test(token) || GUEST_KEY.test(token)) return token++ return `guest:${createHash('sha256').update(token).digest('hex')}`++}@@@ -1,2 +1,5 @@export * from './dto.js'export * from './repositories.js'++export { accountKey } from './accountKey.js'++export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/pgStore.js'++export { MIGRATIONS_DIR } from './migrations.js'nnew file mode 100644@@@ -0,0 +1,4 @@++import { fileURLToPath } from 'node:url'++++/** The migrations directory beside this package (packages/persistence/migrations). */++export const MIGRATIONS_DIR = fileURLToPath(new URL('../migrations', import.meta.url))nnew file mode 100644@@@ -0,0 +1,2 @@++export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pgStore.js'++export { BATCH_CHUNK } from './sqlUtils.js'nnew file mode 100644@@@ -0,0 +1,192 @@++import { describe, expect, it } from 'vitest'++import { sql } from 'openvibe-sdk/db'++import { accountKey } from '../accountKey.js'++import { MIGRATIONS_DIR, openTestDb, openTestStore } from '../testing.js'++import { openPgStore } from './pgStore.js'++import type { PlayerDto, WorldEntityDto } from '../dto.js'++++function makeEntity(id: string): WorldEntityDto {++ return {++ id,++ kind: 'prop',++ defId: 'wooden_crate',++ ownerId: 'player1',++ pos: [1, 2, 3],++ rot: [0, 0, 0, 1],++ motion: 'dynamic',++ state: null,++ updatedAt: 1000,++ }++}++++function makePlayer(overrides: Partial<PlayerDto> = {}): PlayerDto {++ return {++ id: 'p1',++ token: 'usr_01J0000000000000000000000A',++ name: 'Scrapper',++ pos: [0, 1, 0],++ yaw: 1.5,++ armor: { defId: 'padded_jacket', count: 1, meta: { dur: 12 } },++ reputation: { openvibeville: 12, rustjaw: -30 },++ unlocks: ['craft_still'],++ activeJob: { job: 'lumber_run', progress: 3 },++ inventory: {++ size: 24,++ hotbar: 6,++ slots: [{ i: 0, stack: { defId: 'wood_plank', count: 10 } }],++ },++ skills: { woodcutting: 120, mining: 40 },++ friends: ['p2', 'p3'],++ stats: { health: 90, hunger: 80, thirst: 70, stamina: 100 },++ charSlot: 0,++ appearance: {++ body: 'female',++ skin: 3,++ hairStyle: 'bun',++ hairColor: 2,++ facialHair: 'none',++ top: 1,++ bottom: 2,++ shoes: 3,++ height: 1.02,++ build: 0.95,++ },++ updatedAt: 2000,++ ...overrides,++ }++}++++describe('postgres store', () => {++ it('roundtrips world entities with batch upsert and chunking', async () => {++ const store = await openTestStore()++ await store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])++ const loaded = await store.worldEntities.loadAll()++ expect(loaded).toHaveLength(2)++ expect(loaded.find((e) => e.id === 'a')).toEqual(makeEntity('a'))++++ const moved = {++ ...makeEntity('a'),++ pos: [9, 9, 9] as [number, number, number],++ motion: 'frozen' as const,++ }++ await store.worldEntities.upsertMany([moved])++ expect((await store.worldEntities.loadAll()).find((e) => e.id === 'a')).toEqual(moved)++++ // A batch over the 500-row chunk boundary.++ const many = Array.from({ length: 601 }, (_, i) => makeEntity(`bulk-${i}`))++ await store.worldEntities.upsertMany(many)++ expect(await store.worldEntities.loadAll()).toHaveLength(603)++++ await store.worldEntities.deleteMany(['a', 'bulk-0'])++ expect(await store.worldEntities.loadAll()).toHaveLength(601)++ await store.close()++ })++++ it('roundtrips characters by subject token including json columns', async () => {++ const store = await openTestStore()++ const player = makePlayer()++ await store.players.upsert(player)++ expect(await store.players.findByToken(player.token)).toEqual(player)++ expect(await store.players.findByToken('nope')).toBeNull()++ await store.close()++ })++++ it('hashes a guest token: the raw token is never stored', async () => {++ const store = await openTestStore()++ const guest = makePlayer({ id: 'g1', token: 'guestbrowsertoken0123456789abcdef' })++ await store.players.upsert(guest)++ // Lookup by the raw token hashes first and finds the row.++ const found = await store.players.findByToken(guest.token)++ expect(found?.id).toBe('g1')++ expect(found?.token).toBe(accountKey(guest.token))++ expect(found?.token).not.toBe(guest.token)++ // The raw token appears nowhere in the table.++ const rows = await store.db.many<Record<string, unknown>>(sql`SELECT * FROM characters`)++ expect(JSON.stringify(rows)).not.toContain(guest.token)++ expect(rows[0]?.account_key).toBe(accountKey(guest.token))++ await store.close()++ })++++ it('roundtrips constraints with json params', async () => {++ const store = await openTestStore()++ await store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])++ const params = {++ anchorA: [0.5, 0, 0],++ anchorB: [-0.5, 0, 0],++ axisA: [0, 1, 0],++ axisB: [0, 1, 0],++ limits: { min: -1, max: 1 },++ }++ await store.constraints.upsertMany([++ { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },++ { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },++ ])++ expect(await store.constraints.loadAll()).toEqual([++ { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },++ { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },++ ])++ await store.constraints.deleteMany(['c1', 'c2'])++ expect(await store.constraints.loadAll()).toEqual([])++ await store.close()++ })++++ it('persists resource state json and meta values as objects', async () => {++ const store = await openTestStore()++ const node: WorldEntityDto = {++ ...makeEntity('r1'),++ kind: 'resource',++ defId: 'scrap_metal',++ motion: 'static',++ state: { remaining: 12, perUse: 2, provenance: { mapSourceId: 'n1' } },++ }++ await store.worldEntities.upsertMany([node])++ expect((await store.worldEntities.loadAll())[0]?.state).toEqual({++ remaining: 12,++ perUse: 2,++ provenance: { mapSourceId: 'n1' },++ })++ await store.meta.set('env_time', 0.34)++ await store.meta.set('world_seeded', true)++ await store.meta.set('market_village', { wood_plank: { stock: 4, at: 10 } })++ expect(await store.meta.get('env_time')).toBe(0.34)++ expect(await store.meta.get('world_seeded')).toBe(true)++ expect(await store.meta.get('market_village')).toEqual({ wood_plank: { stock: 4, at: 10 } })++ expect(await store.meta.get('missing')).toBeNull()++ await store.close()++ })++++ it('rolls back every write in a transaction and commits together', async () => {++ const store = await openTestStore()++ await expect(++ store.transaction(async () => {++ await store.players.upsert(makePlayer({ id: 'p1' }))++ await store.meta.set('env_weather', 'rain')++ throw new Error('nope')++ }),++ ).rejects.toThrow('nope')++ expect(await store.players.findById('p1')).toBeNull()++ expect(await store.meta.get('env_weather')).toBeNull()++++ await store.transaction(async () => {++ await store.players.upsert(makePlayer({ id: 'p2' }))++ await store.meta.set('env_weather', 'fog')++ })++ expect(await store.players.findById('p2')).not.toBeNull()++ expect(await store.meta.get('env_weather')).toBe('fog')++ await store.close()++ })++++ it('is place-scoped: a second store on the same database sees only its own rows', async () => {++ const db = await openTestDb()++ const a = openPgStore(db, { placeId: 'scraplandia' })++ const b = openPgStore(db, { placeId: 'otherplace' })++ await a.ensurePlace()++ await b.ensurePlace()++ await a.worldEntities.upsertMany([makeEntity('a1')])++ expect(await a.worldEntities.loadAll()).toHaveLength(1)++ expect(await b.worldEntities.loadAll()).toHaveLength(0)++ expect(MIGRATIONS_DIR).toContain('migrations')++ expect(a.placeId).toBe('scraplandia')++ expect(b.placeId).toBe('otherplace')++ })++})nnew file mode 100644@@@ -0,0 +1,370 @@++import { sql, type Db, type Sql, type Tx } from 'openvibe-sdk/db'++import type { InventoryDto, SkillsDto } from '@openvibe/gameplay'++import type { ConstraintDto, PlayerDto, WorldEntityDto } from '../dto.js'++import type {++ ConstraintRepository,++ MetaRepository,++ PersistenceStore,++ PlayerRepository,++ WorldEntityRepository,++} from '../repositories.js'++import { accountKey } from '../accountKey.js'++import {++ createIdentityRepository,++ createMediaMirrorRepository,++ createModRepository,++} from './platformRepositories.js'++import { BATCH_CHUNK, chunks, idList } from './sqlUtils.js'++++export interface PgStoreOptions {++ /** GAMES_PLACE_ID (default 'scraplandia'): the place every world row belongs to on this instance. */++ placeId?: string++}++++export interface PgPersistenceStore extends PersistenceStore {++ readonly db: Db++ readonly placeId: string++}++++interface WorldEntityRow {++ id: string++ kind: string++ def_id: string++ owner_id: string | null++ pos_x: number++ pos_y: number++ pos_z: number++ rot_x: number++ rot_y: number++ rot_z: number++ rot_w: number++ motion: string++ state: unknown++ updated_at: number++}++++interface CharacterRow {++ id: string++ account_key: string++ slot: number++ subject_id: string | null++ name: string++ pos_x: number++ pos_y: number++ pos_z: number++ yaw: number++ inventory: unknown++ skills: unknown++ friends: unknown++ appearance: unknown++ stats: unknown++ armor: unknown++ reputation: unknown++ unlocks: unknown++ // active_job is a text column holding the JSON of the job/progress pair (ADR-0007 job spec).++ active_job: string | null++ updated_at: number++}++++interface ConstraintRow {++ id: string++ type: string++ entity_a: string++ entity_b: string++ params: unknown++ updated_at: number++}++++export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStore {++ const placeId = opts.placeId ?? process.env.GAMES_PLACE_ID ?? 'scraplandia'++++ const worldEntities: WorldEntityRepository = {++ async loadAll(): Promise<WorldEntityDto[]> {++ const rows = await db.many<WorldEntityRow>(++ sql`SELECT id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at++ FROM world_entities WHERE place_id = ${placeId}`,++ )++ return rows.map((row) => ({++ id: row.id,++ kind: row.kind as WorldEntityDto['kind'],++ defId: row.def_id,++ ownerId: row.owner_id,++ pos: [row.pos_x, row.pos_y, row.pos_z],++ rot: [row.rot_x, row.rot_y, row.rot_z, row.rot_w],++ motion: row.motion as WorldEntityDto['motion'],++ state: (row.state ?? null) as WorldEntityDto['state'],++ updatedAt: Number(row.updated_at),++ }))++ },++ async upsertMany(entities: readonly WorldEntityDto[]): Promise<void> {++ for (const chunk of chunks(entities, BATCH_CHUNK)) {++ const rows = chunk.map((e) => ({++ place_id: placeId,++ id: e.id,++ kind: e.kind,++ def_id: e.defId,++ owner_id: e.ownerId,++ pos_x: e.pos[0],++ pos_y: e.pos[1],++ pos_z: e.pos[2],++ rot_x: e.rot[0],++ rot_y: e.rot[1],++ rot_z: e.rot[2],++ rot_w: e.rot[3],++ motion: e.motion,++ state: e.state === null ? null : sql.json(e.state),++ updated_at: e.updatedAt,++ }))++ await db.query(sql`++ INSERT INTO world_entities++ ${sql.insert(rows, [++ 'place_id',++ 'id',++ 'kind',++ 'def_id',++ 'owner_id',++ 'pos_x',++ 'pos_y',++ 'pos_z',++ 'rot_x',++ 'rot_y',++ 'rot_z',++ 'rot_w',++ 'motion',++ 'state',++ 'updated_at',++ ])}++ ON CONFLICT (place_id, id) DO UPDATE SET++ pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,++ rot_x=excluded.rot_x, rot_y=excluded.rot_y, rot_z=excluded.rot_z, rot_w=excluded.rot_w,++ motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at++ `)++ }++ },++ async deleteMany(ids: readonly string[]): Promise<void> {++ for (const chunk of chunks(ids, BATCH_CHUNK)) {++ if (chunk.length === 0) continue++ await db.exec(++ sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,++ )++ }++ },++ async deleteByKind(kind: string): Promise<void> {++ await db.exec(sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND kind = ${kind}`)++ },++ }++++ const characterRow = (p: PlayerDto) => ({++ id: p.id,++ account_key: accountKey(p.token),++ slot: p.charSlot ?? 0,++ subject_id: p.subjectId ?? null,++ name: p.name,++ place_id: placeId,++ pos_x: p.pos[0],++ pos_y: p.pos[1],++ pos_z: p.pos[2],++ yaw: p.yaw,++ inventory: sql.json(p.inventory),++ skills: sql.json(p.skills),++ friends: sql.json(p.friends),++ appearance: p.appearance === null ? null : sql.json(p.appearance),++ stats: p.stats === null ? null : sql.json(p.stats),++ armor: p.armor === null ? null : sql.json(p.armor),++ reputation: sql.json(p.reputation ?? {}),++ unlocks: sql.json(p.unlocks ?? []),++ active_job: p.activeJob === null ? null : JSON.stringify(p.activeJob),++ updated_at: p.updatedAt,++ })++++ const CHARACTER_COLUMNS = [++ 'id',++ 'account_key',++ 'slot',++ 'subject_id',++ 'name',++ 'place_id',++ 'pos_x',++ 'pos_y',++ 'pos_z',++ 'yaw',++ 'inventory',++ 'skills',++ 'friends',++ 'appearance',++ 'stats',++ 'armor',++ 'reputation',++ 'unlocks',++ 'active_job',++ 'updated_at',++ ]++++ const rowToPlayer = (row: CharacterRow): PlayerDto => ({++ id: row.id,++ // The account key doubles as the DTO token: for a Network account it is the subject; for a guest it++ // is `guest:<sha256>`, never the raw browser token (which is not stored).++ token: row.account_key,++ name: row.name,++ pos: [row.pos_x, row.pos_y, row.pos_z],++ yaw: row.yaw,++ inventory: (row.inventory ?? {}) as InventoryDto,++ skills: (row.skills ?? {}) as SkillsDto,++ friends: (row.friends ?? []) as string[],++ appearance: (row.appearance ?? null) as PlayerDto['appearance'],++ armor: (row.armor ?? null) as PlayerDto['armor'],++ reputation: (row.reputation ?? {}) as PlayerDto['reputation'],++ unlocks: (row.unlocks ?? []) as string[],++ activeJob:++ row.active_job === null || row.active_job === undefined++ ? null++ : (JSON.parse(row.active_job) as PlayerDto['activeJob']),++ stats: (row.stats ?? null) as PlayerDto['stats'],++ charSlot: row.slot ?? 0,++ ...(row.subject_id ? { subjectId: row.subject_id } : {}),++ updatedAt: Number(row.updated_at),++ })++++ const characters = sql`SELECT * FROM characters`++ const players: PlayerRepository = {++ async findByToken(token: string): Promise<PlayerDto | null> {++ const row = await db.maybe<CharacterRow>(++ sql`${characters} WHERE account_key = ${accountKey(token)}`,++ )++ return row ? rowToPlayer(row) : null++ },++ async listByToken(token: string): Promise<PlayerDto[]> {++ const rows = await db.many<CharacterRow>(++ sql`${characters} WHERE account_key = ${accountKey(token)} ORDER BY slot`,++ )++ return rows.map(rowToPlayer)++ },++ async findByTokenSlot(token: string, slot: number): Promise<PlayerDto | null> {++ const row = await db.maybe<CharacterRow>(++ sql`${characters} WHERE account_key = ${accountKey(token)} AND slot = ${slot}`,++ )++ return row ? rowToPlayer(row) : null++ },++ async findById(id: string): Promise<PlayerDto | null> {++ const row = await db.maybe<CharacterRow>(sql`${characters} WHERE id = ${id}`)++ return row ? rowToPlayer(row) : null++ },++ async upsert(player: PlayerDto): Promise<void> {++ await db.query(upsertCharacter([player]))++ },++ async upsertMany(list: readonly PlayerDto[]): Promise<void> {++ for (const chunk of chunks(list, BATCH_CHUNK)) {++ if (chunk.length === 0) continue++ await db.query(upsertCharacter(chunk))++ }++ },++ async resetAllPositions(pos: [number, number, number], yaw: number): Promise<void> {++ await db.exec(++ sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw}`,++ )++ },++ }++++ function upsertCharacter(list: readonly PlayerDto[]): Sql {++ return sql`++ INSERT INTO characters++ ${sql.insert(list.map(characterRow), CHARACTER_COLUMNS)}++ ON CONFLICT (id) DO UPDATE SET++ subject_id = COALESCE(excluded.subject_id, characters.subject_id),++ name = excluded.name, place_id = excluded.place_id,++ pos_x = excluded.pos_x, pos_y = excluded.pos_y, pos_z = excluded.pos_z, yaw = excluded.yaw,++ inventory = excluded.inventory, skills = excluded.skills, friends = excluded.friends,++ appearance = excluded.appearance, stats = excluded.stats, armor = excluded.armor,++ reputation = excluded.reputation, unlocks = excluded.unlocks,++ active_job = excluded.active_job, updated_at = excluded.updated_at++ `++ }++++ const constraints: ConstraintRepository = {++ async loadAll(): Promise<ConstraintDto[]> {++ const rows = await db.many<ConstraintRow>(++ sql`SELECT id, type, entity_a, entity_b, params, updated_at FROM world_constraints WHERE place_id = ${placeId}`,++ )++ return rows.map((row) => ({++ id: row.id,++ type: row.type,++ entityA: row.entity_a,++ entityB: row.entity_b,++ params: (row.params ?? null) as Record<string, unknown> | null,++ updatedAt: Number(row.updated_at),++ }))++ },++ async upsertMany(list: readonly ConstraintDto[]): Promise<void> {++ for (const chunk of chunks(list, BATCH_CHUNK)) {++ if (chunk.length === 0) continue++ const rows = chunk.map((c) => ({++ place_id: placeId,++ id: c.id,++ type: c.type,++ entity_a: c.entityA,++ entity_b: c.entityB,++ params: c.params === null ? null : sql.json(c.params),++ updated_at: c.updatedAt,++ }))++ await db.query(sql`++ INSERT INTO world_constraints++ ${sql.insert(rows, ['place_id', 'id', 'type', 'entity_a', 'entity_b', 'params', 'updated_at'])}++ ON CONFLICT (place_id, id) DO UPDATE SET++ params = excluded.params, updated_at = excluded.updated_at++ `)++ }++ },++ async deleteMany(ids: readonly string[]): Promise<void> {++ for (const chunk of chunks(ids, BATCH_CHUNK)) {++ if (chunk.length === 0) continue++ await db.exec(++ sql`DELETE FROM world_constraints WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,++ )++ }++ },++ }++++ const meta: MetaRepository = {++ async get(key: string): Promise<unknown | null> {++ const value = await db.value<unknown>(++ sql`SELECT value FROM world_meta WHERE place_id = ${placeId} AND key = ${key}`,++ )++ return value ?? null++ },++ async set(key: string, value: unknown): Promise<void> {++ await db.exec(sql`++ INSERT INTO world_meta (place_id, key, value) VALUES (${placeId}, ${key}, ${sql.json(value)})++ ON CONFLICT (place_id, key) DO UPDATE SET value = excluded.value++ `)++ },++ async list(prefix: string): Promise<{ key: string; value: unknown }[]> {++ const rows = await db.many<{ key: string; value: unknown }>(++ sql`SELECT key, value FROM world_meta WHERE place_id = ${placeId} AND key LIKE ${`${prefix}%`} ORDER BY key`,++ )++ return rows.map((r) => ({ key: r.key, value: r.value }))++ },++ }++++ return {++ db,++ placeId,++ async ensurePlace(name = placeId): Promise<void> {++ await db.exec(++ sql`INSERT INTO places (id, name) VALUES (${placeId}, ${name}) ON CONFLICT (id) DO NOTHING`,++ )++ },++ worldEntities,++ players,++ constraints,++ meta,++ identity: createIdentityRepository(db),++ mods: createModRepository(db),++ mediaMirrors: createMediaMirrorRepository(db),++ transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T> {++ return db.tx(fn)++ },++ close(): Promise<void> {++ return db.close()++ },++ }++}nnew file mode 100644@@@ -0,0 +1,287 @@++import { describe, expect, it } from 'vitest'++import { sql } from 'openvibe-sdk/db'++import type { PlayerDto } from '../dto.js'++import { openTestStore } from '../testing.js'++++const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'++const OTHER = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'++++function player(++ id: string,++ token: string,++ slot: number,++ extra: Partial<PlayerDto> = {},++): PlayerDto {++ return {++ id,++ token,++ name: `char-${id}`,++ pos: [0, 1, 0],++ yaw: 0,++ inventory: { size: 24, hotbar: 6, slots: [] },++ skills: { woodcutting: 10 },++ friends: [],++ appearance: null,++ charSlot: slot,++ armor: null,++ reputation: {},++ unlocks: [],++ activeJob: null,++ stats: null,++ updatedAt: 1,++ ...extra,++ }++}++++describe('guest conversion (WS-B task 8)', () => {++ it('moves the guest character into the first free slot, once, keyed by a hash of the token', async () => {++ const store = await openTestStore()++ await store.players.upsertMany([player('g1', 'guest00042abc', 0), player('a0', SUBJECT, 0)])++ expect(await store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 10)).toEqual({++ moved: 1,++ slot: 1,++ full: false,++ })++ expect(++ (await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),++ ).toEqual([++ ['a0', 0],++ ['g1', 1],++ ])++ expect(await store.players.listByToken('guest00042abc')).toEqual([])++ expect(await store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 11)).toEqual({++ moved: 0,++ slot: null,++ full: false,++ })++ const keys = (++ await store.db.many<{ adoption_key: string }>(++ sql`SELECT adoption_key FROM identity_adoptions`,++ )++ ).map((r) => r.adoption_key)++ expect(keys.some((k) => k.includes('guest00042abc'))).toBe(false)++ expect(keys.some((k) => /^guest:[0-9a-f]{64}$/.test(k))).toBe(true)++ })++++ it('keeps the guest character when every slot is taken, and does nothing without one', async () => {++ const store = await openTestStore()++ await store.players.upsertMany([++ player('a0', SUBJECT, 0),++ player('a1', SUBJECT, 1),++ player('a2', SUBJECT, 2),++ player('g1', 'guestfull01', 0),++ ])++ expect(await store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({++ moved: 0,++ slot: null,++ full: true,++ })++ expect((await store.players.listByToken('guestfull01')).length).toBe(1)++ expect(await store.identity.adoptGuestCharacter('nobodyhere01', SUBJECT, 1)).toEqual({++ moved: 0,++ slot: null,++ full: false,++ })++ })++})++++describe('account merge (ADR-029)', () => {++ const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'++ it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', async () => {++ const store = await openTestStore()++ // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.++ await store.players.upsertMany([++ player('s0', SUBJECT, 0),++ player('f0', FROM, 0),++ player('f1', FROM, 1),++ player('f2', FROM, 2),++ ])++ expect(++ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),++ ).toEqual({ moved: 2, kept: 1, already: false })++ expect(++ (await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),++ ).toEqual([++ ['f1', 1],++ ['f2', 2],++ ['s0', 0],++ ])++ expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])++ expect(++ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),++ ).toEqual({ moved: 0, kept: 0, already: true })++ expect((await store.players.listByToken(FROM)).length).toBe(1)++ })++})++++describe('account export and deletion (ADR-033)', () => {++ const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'++ it("exports the subject's characters without the account key, then erases them and the alias's; structures stay unowned", async () => {++ const store = await openTestStore()++ await store.players.upsertMany([++ player('d0', SUBJECT, 0),++ player('d1', ALIAS, 0),++ player('o0', OTHER, 0),++ ])++ for (const [id, owner] of [++ ['e1', 'd0'],++ ['e2', 'o0'],++ ] as const) {++ await store.db.exec(sql`++ INSERT INTO world_entities (place_id, id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at)++ VALUES (${store.placeId}, ${id}, 'structure', 'hut', ${owner}, 0, 0, 0, 0, 0, 0, 1, 'static', '{}'::jsonb, 1)++ `)++ }++ await store.db.exec(sql`++ INSERT INTO identity_adoptions (adoption_key, subject_id, source, adopted_at)++ VALUES ('merge:mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', ${SUBJECT}, 'merge', 1)++ `)++ const out = await store.identity.exportSubject(SUBJECT)++ expect(out.characters.map((c) => c.id)).toEqual(['d0'])++ expect(out.characters[0]).not.toHaveProperty('token')++ expect(out.characters[0]).not.toHaveProperty('account_key')++ expect(out.characters[0]?.skills).toEqual({ woodcutting: 10 })++ expect(out.structures.map((e) => e.id)).toEqual(['e1'])++ expect(await store.identity.eraseSubjects([SUBJECT, ALIAS])).toEqual({++ characters: 2,++ structures_unowned: 1,++ identity_rows: 1,++ })++ expect(await store.players.listByToken(SUBJECT)).toEqual([])++ expect(await store.players.listByToken(ALIAS)).toEqual([])++ expect((await store.players.listByToken(OTHER)).map((p) => p.id)).toEqual(['o0'])++ expect(++ await store.db.many<{ id: string; owner_id: string | null }>(++ sql`SELECT id, owner_id FROM world_entities ORDER BY id`,++ ),++ ).toEqual([++ { id: 'e1', owner_id: null },++ { id: 'e2', owner_id: 'o0' },++ ])++ expect(await store.identity.eraseSubjects([SUBJECT])).toEqual({++ characters: 0,++ structures_unowned: 0,++ identity_rows: 0,++ })++ })++})++++describe('player subject_id', () => {++ it('keeps subject_id through ordinary saves that do not carry it', async () => {++ const store = await openTestStore()++ await store.players.upsert(player('p1', SUBJECT, 0, { subjectId: SUBJECT }))++ const loaded = (await store.players.findById('p1'))!++ const { subjectId: _drop, ...withoutSubject } = loaded++ await store.players.upsert({ ...withoutSubject, name: 'renamed' })++ expect(await store.players.findById('p1')).toMatchObject({++ name: 'renamed',++ subjectId: SUBJECT,++ })++ })++})++++describe('mod repository', () => {++ it('stores installs, grants, placements and an append-only audit log', async () => {++ const store = await openTestStore()++ const id = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'++ await store.mods.insert({++ id,++ name: 'Test',++ version: '1.0.0',++ target: 'games.browser',++ runtime: 'games-content@1',++ manifest: { a: 1 },++ pack: { props: [] },++ trustTier: 'unreviewed',++ status: 'disabled',++ installedBy: SUBJECT,++ installedAt: 1,++ updatedAt: 1,++ })++ await store.mods.upsertGrant({++ modId: id,++ capability: 'games.world.announce',++ grantedBy: SUBJECT,++ grantedAt: 2,++ revokedAt: null,++ revokedBy: null,++ })++ expect(await store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 3)).toBe(true)++ expect(await store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 4)).toBe(false)++ expect((await store.mods.grants(id))[0]).toMatchObject({ revokedAt: 3, revokedBy: SUBJECT })++++ await store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e1', at: 5 })++ await store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e2', at: 6 })++ expect(await store.mods.placements(id)).toEqual([++ { modId: id, key: 'bench', entityId: 'e2', at: 6 },++ ])++ await store.mods.deletePlacement(id, 'bench')++ expect(await store.mods.placements(id)).toEqual([])++++ await store.mods.audit({++ modId: id,++ action: 'install',++ capability: null,++ actor: SUBJECT,++ detail: null,++ at: 1,++ })++ await store.mods.audit({++ modId: id,++ action: 'grant',++ capability: 'games.world.announce',++ actor: SUBJECT,++ detail: { x: 1 },++ at: 2,++ })++ expect((await store.mods.auditLog(id, 10)).map((a) => a.action)).toEqual(['grant', 'install'])++ await store.mods.setStatus(id, 'revoked', 9)++ expect(await store.mods.get(id)).toMatchObject({++ status: 'revoked',++ updatedAt: 9,++ manifest: { a: 1 },++ })++ })++})++++describe('media mirror queue', () => {++ it('queues once, schedules retries and records the Media object', async () => {++ const store = await openTestStore()++ const asset = { assetHash: 'sha256-aa', fileName: 'sha256-aa.png', mime: 'image/png', bytes: 3 }++ expect(await store.mediaMirrors.enqueue(asset, 10)).toBe(true)++ expect(await store.mediaMirrors.enqueue(asset, 11)).toBe(false)++ expect(await store.mediaMirrors.due(10, 5)).toHaveLength(1)++ await store.mediaMirrors.markFailed('sha256-aa', 'http.503: down', 100, false, 12)++ expect(await store.mediaMirrors.due(50, 5)).toHaveLength(0)++ expect(await store.mediaMirrors.due(100, 5)).toHaveLength(1)++ await store.mediaMirrors.noteObject('sha256-aa', 'med_X', 13)++ await store.mediaMirrors.markMirrored(++ 'sha256-aa',++ 'med_X',++ 'https://openvibe.media/o/med_X',++ 14,++ )++ expect(await store.mediaMirrors.get('sha256-aa')).toMatchObject({++ status: 'mirrored',++ mediaId: 'med_X',++ attempts: 2,++ lastError: null,++ })++ expect(await store.mediaMirrors.counts()).toEqual({ pending: 0, mirrored: 1, failed: 0 })++ })++})++++describe('store transactions', () => {++ it('rolls back every repository write together', async () => {++ const store = await openTestStore()++ await expect(++ store.transaction(async () => {++ await store.players.upsert(player('p1', SUBJECT, 0))++ await store.meta.set('env_time', '0.5')++ throw new Error('boom')++ }),++ ).rejects.toThrow('boom')++ expect(await store.players.findById('p1')).toBeNull()++ expect(await store.meta.get('env_time')).toBeNull()++ })++})nnew file mode 100644@@@ -0,0 +1,396 @@++import { sql, type Db } from 'openvibe-sdk/db'++import { accountKey } from '../accountKey.js'++import type {++ MediaMirrorDto,++ ModAuditDto,++ ModGrantDto,++ ModInstallDto,++ ModPlacementDto,++ ModStatus,++ ModTrustTier,++} from '../dto.js'++import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'++import { idList } from './sqlUtils.js'++++/**++ * PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry++ * and the Media mirror queue. The game simulation never touches these; apps/server's platform++ * adapters do.++ */++++/** An exported character row without its account key (the sign-in identity). */++function withoutAccountKey(row: Record<string, unknown>): Record<string, unknown> {++ const { account_key: _ignored, ...rest } = row++ return rest++}++++export function createIdentityRepository(db: Db): IdentityRepository {++ async function takenSlots(account: string): Promise<Set<number>> {++ const rows = await db.many<{ slot: number }>(++ sql`SELECT slot FROM characters WHERE account_key = ${account}`,++ )++ return new Set(rows.map((r) => r.slot))++ }++ async function rowsFor(account: string): Promise<{ id: string; slot: number }[]> {++ return db.many<{ id: string; slot: number }>(++ sql`SELECT id, slot FROM characters WHERE account_key = ${account} ORDER BY slot`,++ )++ }++ const adopted = (key: string) =>++ db.maybe<{ subject_id: string }>(++ sql`SELECT subject_id FROM identity_adoptions WHERE adoption_key = ${key}`,++ )++ const moveToSlot = (id: string, account: string, subject: string, slot: number) =>++ db.exec(++ sql`UPDATE characters SET account_key = ${account}, subject_id = ${subject}, slot = ${slot} WHERE id = ${id}`,++ )++ const recordAdoption = (++ key: string,++ subject: string,++ source: string,++ moved: number,++ conflicts: number,++ now: number,++ ) =>++ db.exec(sql`++ INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)++ VALUES (${key}, ${subject}, ${source}, ${moved}, ${conflicts}, ${now})++ ON CONFLICT (adoption_key) DO UPDATE SET++ moved = identity_adoptions.moved + excluded.moved,++ conflicts = excluded.conflicts++ `)++++ return {++ adoptGuestCharacter(guestToken, subjectId, now) {++ return db.tx(async () => {++ // Once per guest token, remembered by a hash of it, never the token.++ const key = accountKey(guestToken)++ const none = { moved: 0, slot: null, full: false }++ if (await adopted(key)) return none++ const rows = await rowsFor(key)++ if (rows.length === 0) return none++ const taken = await takenSlots(subjectId)++ let moved = 0++ let slot: number | null = null++ for (const row of rows) {++ const free = [0, 1, 2].find((s) => !taken.has(s))++ if (free === undefined) break++ await moveToSlot(row.id, subjectId, subjectId, free)++ taken.add(free)++ slot ??= free++ moved++++ }++ if (moved === 0) return { moved: 0, slot: null, full: true }++ await recordAdoption(key, subjectId, 'guest', moved, rows.length - moved, now)++ return { moved, slot, full: false }++ })++ },++++ mergeSubject(from, into, mergeId, now) {++ return db.tx(async () => {++ const key = `merge:${mergeId}`++ if (await adopted(key)) return { moved: 0, kept: 0, already: true }++ const taken = await takenSlots(into)++ const rows = await rowsFor(from)++ let moved = 0++ // First every character whose own slot is free there, then the rest into what is left.++ const clashing: { id: string; slot: number }[] = []++ for (const row of rows) {++ if (taken.has(row.slot)) {++ clashing.push(row)++ continue++ }++ await moveToSlot(row.id, into, into, row.slot)++ taken.add(row.slot)++ moved++++ }++ let kept = 0++ for (const row of clashing) {++ const slot = [0, 1, 2].find((s) => !taken.has(s))++ if (slot === undefined) {++ kept++++ continue++ }++ await moveToSlot(row.id, into, into, slot)++ taken.add(slot)++ moved++++ }++ await recordAdoption(key, into, 'merge', moved, kept, now)++ return { moved, kept, already: false }++ })++ },++++ async exportSubject(subject) {++ const characters = await db.many<Record<string, unknown>>(++ sql`SELECT * FROM characters WHERE account_key = ${subject} OR subject_id = ${subject} ORDER BY slot`,++ )++ const ids = characters.map((c) => String(c.id))++ const structures = ids.length++ ? await db.many<Record<string, unknown>>(++ sql`SELECT * FROM world_entities WHERE owner_id IN (${idList(ids)})`,++ )++ : []++ return {++ characters: characters.map(withoutAccountKey),++ structures,++ }++ },++++ eraseSubjects(subjects) {++ return db.tx(async () => {++ const out = { characters: 0, structures_unowned: 0, identity_rows: 0 }++ if (!subjects.length) return out++ const ids = (++ await db.many<{ id: string }>(++ sql`SELECT id FROM characters WHERE account_key IN (${idList(subjects)}) OR subject_id IN (${idList(subjects)})`,++ )++ ).map((r) => r.id)++ if (ids.length) {++ out.structures_unowned = await db.exec(++ sql`UPDATE world_entities SET owner_id = NULL WHERE owner_id IN (${idList(ids)})`,++ )++ out.characters = await db.exec(sql`DELETE FROM characters WHERE id IN (${idList(ids)})`)++ }++ out.identity_rows = await db.exec(++ sql`DELETE FROM identity_adoptions WHERE subject_id IN (${idList(subjects)})`,++ )++ return out++ })++ },++ }++}++++interface ModRow {++ id: string++ name: string++ version: string++ target: string++ runtime: string++ manifest: unknown++ pack: unknown++ trust_tier: string++ status: string++ installed_by: string++ installed_at: number++ updated_at: number++}++++interface GrantRow {++ mod_id: string++ capability: string++ granted_by: string++ granted_at: number++ revoked_at: number | null++ revoked_by: string | null++}++++interface AuditRow {++ id: number++ mod_id: string++ action: string++ capability: string | null++ actor: string++ detail: unknown++ at: number++}++++interface PlacementRow {++ mod_id: string++ placement_key: string++ entity_id: string | null++ at: number++}++++const rowToMod = (r: ModRow): ModInstallDto => ({++ id: r.id,++ name: r.name,++ version: r.version,++ target: r.target,++ runtime: r.runtime,++ manifest: (r.manifest ?? {}) as Record<string, unknown>,++ pack: (r.pack ?? {}) as Record<string, unknown>,++ trustTier: r.trust_tier as ModTrustTier,++ status: r.status as ModStatus,++ installedBy: r.installed_by,++ installedAt: Number(r.installed_at),++ updatedAt: Number(r.updated_at),++})++++const rowToGrant = (r: GrantRow): ModGrantDto => ({++ modId: r.mod_id,++ capability: r.capability,++ grantedBy: r.granted_by,++ grantedAt: Number(r.granted_at),++ revokedAt: r.revoked_at === null ? null : Number(r.revoked_at),++ revokedBy: r.revoked_by,++})++++export function createModRepository(db: Db): ModRepository {++ return {++ async list() {++ const rows = await db.many<ModRow>(sql`SELECT * FROM mods ORDER BY installed_at, id`)++ return rows.map(rowToMod)++ },++ async get(id) {++ const row = await db.maybe<ModRow>(sql`SELECT * FROM mods WHERE id = ${id}`)++ return row ? rowToMod(row) : null++ },++ async insert(mod) {++ await db.exec(sql`++ INSERT INTO mods (id, name, version, target, runtime, manifest, pack, trust_tier, status, installed_by, installed_at, updated_at)++ VALUES (${mod.id}, ${mod.name}, ${mod.version}, ${mod.target}, ${mod.runtime},++ ${sql.json(mod.manifest)}, ${sql.json(mod.pack)}, ${mod.trustTier}, ${mod.status},++ ${mod.installedBy}, ${mod.installedAt}, ${mod.updatedAt})++ `)++ },++ async setStatus(id, status, at) {++ await db.exec(sql`UPDATE mods SET status = ${status}, updated_at = ${at} WHERE id = ${id}`)++ },++ async grants(modId) {++ const rows = await db.many<GrantRow>(++ sql`SELECT * FROM mod_grants WHERE mod_id = ${modId} ORDER BY capability`,++ )++ return rows.map(rowToGrant)++ },++ async upsertGrant(g) {++ await db.exec(sql`++ INSERT INTO mod_grants (mod_id, capability, granted_by, granted_at, revoked_at, revoked_by)++ VALUES (${g.modId}, ${g.capability}, ${g.grantedBy}, ${g.grantedAt}, ${g.revokedAt}, ${g.revokedBy})++ ON CONFLICT (mod_id, capability) DO UPDATE SET++ granted_by = excluded.granted_by, granted_at = excluded.granted_at,++ revoked_at = excluded.revoked_at, revoked_by = excluded.revoked_by++ `)++ },++ async revokeGrant(modId, capability, by, at) {++ const changes = await db.exec(sql`++ UPDATE mod_grants SET revoked_at = ${at}, revoked_by = ${by}++ WHERE mod_id = ${modId} AND capability = ${capability} AND revoked_at IS NULL++ `)++ return changes > 0++ },++ async audit(e) {++ await db.exec(sql`++ INSERT INTO mod_audit (mod_id, action, capability, actor, detail, at)++ VALUES (${e.modId}, ${e.action}, ${e.capability}, ${e.actor},++ ${e.detail === null || e.detail === undefined ? null : sql.json(e.detail)}, ${e.at})++ `)++ },++ async auditLog(modId, limit) {++ const rows = await db.many<AuditRow>(++ sql`SELECT * FROM mod_audit WHERE mod_id = ${modId} ORDER BY id DESC LIMIT ${limit}`,++ )++ return rows.map((r): ModAuditDto => ({++ id: Number(r.id),++ modId: r.mod_id,++ action: r.action,++ capability: r.capability,++ actor: r.actor,++ detail: (r.detail ?? null) as Record<string, unknown> | null,++ at: Number(r.at),++ }))++ },++ async placements(modId) {++ const rows = await db.many<PlacementRow>(++ sql`SELECT * FROM mod_placements WHERE mod_id = ${modId} ORDER BY placement_key`,++ )++ return rows.map((r): ModPlacementDto => ({++ modId: r.mod_id,++ key: r.placement_key,++ entityId: r.entity_id,++ at: Number(r.at),++ }))++ },++ async setPlacement(p) {++ await db.exec(sql`++ INSERT INTO mod_placements (mod_id, placement_key, entity_id, at)++ VALUES (${p.modId}, ${p.key}, ${p.entityId}, ${p.at})++ ON CONFLICT (mod_id, placement_key) DO UPDATE SET entity_id = excluded.entity_id, at = excluded.at++ `)++ },++ async deletePlacement(modId, key) {++ await db.exec(++ sql`DELETE FROM mod_placements WHERE mod_id = ${modId} AND placement_key = ${key}`,++ )++ },++ }++}++++interface MirrorRow {++ asset_hash: string++ file_name: string++ mime: string++ bytes: number++ status: string++ media_id: string | null++ public_url: string | null++ attempts: number++ last_error: string | null++ next_attempt_at: number++ updated_at: number++}++++const rowToMirror = (r: MirrorRow): MediaMirrorDto => ({++ assetHash: r.asset_hash,++ fileName: r.file_name,++ mime: r.mime,++ bytes: Number(r.bytes),++ status: r.status as MediaMirrorDto['status'],++ mediaId: r.media_id,++ publicUrl: r.public_url,++ attempts: Number(r.attempts),++ lastError: r.last_error,++ nextAttemptAt: Number(r.next_attempt_at),++ updatedAt: Number(r.updated_at),++})++++export function createMediaMirrorRepository(db: Db): MediaMirrorRepository {++ return {++ async enqueue(asset, now) {++ const changes = await db.exec(sql`++ INSERT INTO media_mirrors (asset_hash, file_name, mime, bytes, status, attempts, next_attempt_at, updated_at)++ VALUES (${asset.assetHash}, ${asset.fileName}, ${asset.mime}, ${asset.bytes}, 'pending', 0, ${now}, ${now})++ ON CONFLICT (asset_hash) DO NOTHING++ `)++ return changes > 0++ },++ async get(assetHash) {++ const row = await db.maybe<MirrorRow>(++ sql`SELECT * FROM media_mirrors WHERE asset_hash = ${assetHash}`,++ )++ return row ? rowToMirror(row) : null++ },++ async due(now, limit) {++ const rows = await db.many<MirrorRow>(sql`++ SELECT * FROM media_mirrors WHERE status = 'pending' AND next_attempt_at <= ${now}++ ORDER BY next_attempt_at, asset_hash LIMIT ${limit}++ `)++ return rows.map(rowToMirror)++ },++ async noteObject(assetHash, mediaId, now) {++ await db.exec(++ sql`UPDATE media_mirrors SET media_id = ${mediaId}, updated_at = ${now} WHERE asset_hash = ${assetHash}`,++ )++ },++ async markMirrored(assetHash, mediaId, publicUrl, now) {++ await db.exec(sql`++ UPDATE media_mirrors SET status = 'mirrored', media_id = ${mediaId}, public_url = ${publicUrl},++ last_error = NULL, attempts = attempts + 1, updated_at = ${now} WHERE asset_hash = ${assetHash}++ `)++ },++ async markFailed(assetHash, error, nextAttemptAt, terminal, now) {++ await db.exec(sql`++ UPDATE media_mirrors SET status = ${terminal ? 'failed' : 'pending'}, last_error = ${error.slice(0, 500)},++ attempts = attempts + 1, next_attempt_at = ${nextAttemptAt}, updated_at = ${now}++ WHERE asset_hash = ${assetHash}++ `)++ },++ async counts() {++ const out: Record<MediaMirrorDto['status'], number> = { pending: 0, mirrored: 0, failed: 0 }++ const rows = await db.many<{ status: MediaMirrorDto['status']; n: number }>(++ sql`SELECT status, COUNT(*) AS n FROM media_mirrors GROUP BY status`,++ )++ for (const r of rows) out[r.status] = Number(r.n)++ return out++ },++ }++}nnew file mode 100644@@@ -0,0 +1,13 @@++import { sql, type Sql } from 'openvibe-sdk/db'++++/** Multi-row INSERT … ON CONFLICT chunks stay at or below this many rows. */++export const BATCH_CHUNK = 500++++/** `($1, $2, …)` from a list of values, for IN clauses. Never call with an empty list. */++export function idList(values: readonly string[]): Sql {++ return sql.join(values.map((v) => sql`${v}`))++}++++export function* chunks<T>(rows: readonly T[], size: number): Generator<T[]> {++ for (let i = 0; i < rows.length; i += size) yield rows.slice(i, i + size)++}@@@ -1,3 +1,4 @@++import type { Db, Tx } from 'openvibe-sdk/db'import type {ConstraintDto,MediaMirrorDto,@@@ -12,36 +13,38 @@ import type {/*** Repository boundary: gameplay/server code never touches SQL or the-- * database driver. Writes are designed to be batched — the server flushes-- * dirty entities periodically and on shutdown, not per mutation.++ * database driver. Every method is async (PostgreSQL through openvibe-sdk/db);++ * the server's write-behind flusher batches world rows in one transaction++ * rather than writing per mutation.*/export interface WorldEntityRepository {-- loadAll(): WorldEntityDto[]-- /** Transactional batch upsert. */-- upsertMany(entities: readonly WorldEntityDto[]): void-- deleteMany(ids: readonly string[]): void++ loadAll(): Promise<WorldEntityDto[]>++ /** Batch upsert, chunked multi-row INSERT … ON CONFLICT. */++ upsertMany(entities: readonly WorldEntityDto[]): Promise<void>++ deleteMany(ids: readonly string[]): Promise<void>/** Bulk removal used on world-definition changes (e.g. all resource nodes). */-- deleteByKind(kind: string): void++ deleteByKind(kind: string): Promise<void>}export interface PlayerRepository {-- findByToken(token: string): PlayerDto | null++ /** Looks a character up by account token; a guest token is hashed first. */++ findByToken(token: string): Promise<PlayerDto | null>/** All characters under an account token (max 3, ordered by slot). */-- listByToken(token: string): PlayerDto[]-- findByTokenSlot(token: string, slot: number): PlayerDto | null++ listByToken(token: string): Promise<PlayerDto[]>++ findByTokenSlot(token: string, slot: number): Promise<PlayerDto | null>/** Offline lookups (prop protection checks owners who are not connected). */-- findById(id: string): PlayerDto | null-- upsert(player: PlayerDto): void-- upsertMany(players: readonly PlayerDto[]): void++ findById(id: string): Promise<PlayerDto | null>++ upsert(player: PlayerDto): Promise<void>++ upsertMany(players: readonly PlayerDto[]): Promise<void>/** World-change safety: move every player to the given spawn. */-- resetAllPositions(pos: [number, number, number], yaw: number): void++ resetAllPositions(pos: [number, number, number], yaw: number): Promise<void>}export interface ConstraintRepository {-- loadAll(): ConstraintDto[]-- upsertMany(constraints: readonly ConstraintDto[]): void-- deleteMany(ids: readonly string[]): void++ loadAll(): Promise<ConstraintDto[]>++ upsertMany(constraints: readonly ConstraintDto[]): Promise<void>++ deleteMany(ids: readonly string[]): Promise<void>}/**@@@ -61,7 +64,7 @@ export interface IdentityRepository {guestToken: string,subjectId: string,now: number,-- ): { moved: number; slot: number | null; full: boolean }++ ): Promise<{ moved: number; slot: number | null; full: boolean }>/*** Account merge (roadmap WS-B task 5, ADR-029; network.subject.merged): the folded-in subject's characters* move to the survivor, each into its own slot when free there, else the first free one (0..2). A character@@@ -73,43 +76,43 @@ export interface IdentityRepository {intoSubject: string,mergeId: string,now: number,-- ): { moved: number; kept: number; already: boolean }++ ): Promise<{ moved: number; kept: number; already: boolean }>/*** What Games keeps about a subject, for their data export (roadmap WS-B task 7, ADR-033): their characters (without-- * the sign-in token) and the world structures those characters own.++ * the account key) and the world structures those characters own.*/-- exportSubject(subject: string): {++ exportSubject(subject: string): Promise<{characters: Record<string, unknown>[]structures: Record<string, unknown>[]-- }++ }>/*** Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their* characters and adoption rows go; the structures their characters built stay in the world without an owner.* Returns counts.*/-- eraseSubjects(subjects: string[]): {++ eraseSubjects(subjects: string[]): Promise<{characters: numberstructures_unowned: numberidentity_rows: number-- }++ }>}/** Installed mods, their approved capabilities, placements and audit log. */export interface ModRepository {-- list(): ModInstallDto[]-- get(id: string): ModInstallDto | null-- insert(mod: ModInstallDto): void-- setStatus(id: string, status: ModStatus, at: number): void-- grants(modId: string): ModGrantDto[]++ list(): Promise<ModInstallDto[]>++ get(id: string): Promise<ModInstallDto | null>++ insert(mod: ModInstallDto): Promise<void>++ setStatus(id: string, status: ModStatus, at: number): Promise<void>++ grants(modId: string): Promise<ModGrantDto[]>/** Grants (or re-grants) a capability. */-- upsertGrant(grant: ModGrantDto): void++ upsertGrant(grant: ModGrantDto): Promise<void>/** Returns false when the capability was not actively granted. */-- revokeGrant(modId: string, capability: string, by: string, at: number): boolean-- audit(entry: ModAuditDto): void-- auditLog(modId: string, limit: number): ModAuditDto[]-- placements(modId: string): ModPlacementDto[]-- setPlacement(placement: ModPlacementDto): void-- deletePlacement(modId: string, key: string): void++ revokeGrant(modId: string, capability: string, by: string, at: number): Promise<boolean>++ audit(entry: ModAuditDto): Promise<void>++ auditLog(modId: string, limit: number): Promise<ModAuditDto[]>++ placements(modId: string): Promise<ModPlacementDto[]>++ setPlacement(placement: ModPlacementDto): Promise<void>++ deletePlacement(modId: string, key: string): Promise<void>}/** Queue of local assets to copy into OpenVibe.Media. */@@@ -118,29 +121,47 @@ export interface MediaMirrorRepository {enqueue(asset: { assetHash: string; fileName: string; mime: string; bytes: number },now: number,-- ): boolean-- get(assetHash: string): MediaMirrorDto | null++ ): Promise<boolean>++ get(assetHash: string): Promise<MediaMirrorDto | null>/** Pending rows whose next attempt is due, oldest first. */-- due(now: number, limit: number): MediaMirrorDto[]++ due(now: number, limit: number): Promise<MediaMirrorDto[]>/** Remembers the Media object created for an asset before its bytes are confirmed. */-- noteObject(assetHash: string, mediaId: string, now: number): void-- markMirrored(assetHash: string, mediaId: string, publicUrl: string | null, now: number): void++ noteObject(assetHash: string, mediaId: string, now: number): Promise<void>++ markMirrored(++ assetHash: string,++ mediaId: string,++ publicUrl: string | null,++ now: number,++ ): Promise<void>markFailed(assetHash: string,error: string,nextAttemptAt: number,terminal: boolean,now: number,-- ): void-- counts(): Record<MediaMirrorDto['status'], number>++ ): Promise<void>++ counts(): Promise<Record<MediaMirrorDto['status'], number>>}++/** Per-place key/value state. Values are JSON: env_time, env_weather, market_<id>, … */export interface MetaRepository {-- get(key: string): string | null-- set(key: string, value: string): void++ get(key: string): Promise<unknown | null>++ set(key: string, value: unknown): Promise<void>++ /** Every key beginning with `prefix`, for boot-time loads (markets). */++ list(prefix: string): Promise<{ key: string; value: unknown }[]>}export interface PersistenceStore {++ /**++ * The openvibe-sdk/db handle. Exposed ONLY for apps/server's platform adapters that must share the++ * exact same transaction as the state they describe — the event outbox (createPgOutbox), the token++ * revocations (createPgRevocationStore) and the account-data adapter. Gameplay code goes through the++ * repositories below and never touches this.++ */++ readonly db: Db++ readonly placeId: string++ /** Upserts this instance's configured place; call once at boot before any world write. */++ ensurePlace(name?: string): Promise<void>readonly worldEntities: WorldEntityRepositoryreadonly players: PlayerRepositoryreadonly constraints: ConstraintRepository@@@ -149,10 +170,12 @@ export interface PersistenceStore {readonly mods: ModRepositoryreadonly mediaMirrors: MediaMirrorRepository/**-- * Runs `fn` in one transaction (nested calls become savepoints). Anything-- * written inside — world rows, player rows, outbox events — commits or-- * rolls back together.++ * Runs `fn` in one transaction (a nested call becomes a savepoint). Anything written inside — world++ * rows, character rows, outbox events — commits or rolls back together. Plain repository and db calls++ * made inside `fn` join the transaction (the SDK's ambient mode). `fn` receives the SDK transaction++ * handle: the outbox must be handed exactly this handle, so an event cannot be written on a different++ * connection than the change it describes.*/-- transaction<T>(fn: () => T): T-- close(): void++ transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T>++ close(): Promise<void>}ddeleted file mode 100644@@@ -1 +0,0 @@--export { openSqliteStore, type SqlitePersistenceStore } from './sqliteStore.js'ddeleted file mode 100644@@@ -1,285 +0,0 @@--import { mkdtempSync } from 'node:fs'--import { tmpdir } from 'node:os'--import { join } from 'node:path'--import { describe, expect, it } from 'vitest'--import type { PlayerDto } from '../dto.js'--import { openSqliteStore } from './sqliteStore.js'----const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'--const OTHER = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'----function player(-- id: string,-- token: string,-- slot: number,-- extra: Partial<PlayerDto> = {},--): PlayerDto {-- return {-- id,-- token,-- name: `char-${id}`,-- pos: [0, 1, 0],-- yaw: 0,-- inventory: { size: 24, hotbar: 6, slots: [] },-- skills: { woodcutting: 10 },-- friends: [],-- appearance: null,-- charSlot: slot,-- armor: null,-- reputation: {},-- unlocks: [],-- activeJob: null,-- stats: null,-- updatedAt: 1,-- ...extra,-- }--}----describe('guest conversion (WS-B task 8)', () => {-- it('moves the guest character into the first free slot, once, keyed by a hash of the token', () => {-- const store = openSqliteStore(':memory:')-- store.players.upsertMany([player('g1', 'guest00042abc', 0), player('a0', SUBJECT, 0)])-- expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 10)).toEqual({-- moved: 1,-- slot: 1,-- full: false,-- })-- expect(-- store.players-- .listByToken(SUBJECT)-- .map((p) => [p.id, p.charSlot])-- .sort(),-- ).toEqual([-- ['a0', 0],-- ['g1', 1],-- ])-- expect(store.players.listByToken('guest00042abc')).toEqual([])-- expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 11)).toEqual({-- moved: 0,-- slot: null,-- full: false,-- })-- const keys = (-- store.db.prepare('SELECT adoption_key FROM identity_adoptions').all() as {-- adoption_key: string-- }[]-- ).map((r) => r.adoption_key)-- expect(keys.some((k) => k.includes('guest00042abc'))).toBe(false)-- expect(keys.some((k) => /^guest:[0-9a-f]{32}$/.test(k))).toBe(true)-- store.close()-- })---- it('keeps the guest character when every slot is taken, and does nothing without one', () => {-- const store = openSqliteStore(':memory:')-- store.players.upsertMany([-- player('a0', SUBJECT, 0),-- player('a1', SUBJECT, 1),-- player('a2', SUBJECT, 2),-- player('g1', 'guestfull01', 0),-- ])-- expect(store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({-- moved: 0,-- slot: null,-- full: true,-- })-- expect(store.players.listByToken('guestfull01').length).toBe(1)-- expect(store.identity.adoptGuestCharacter('nobodyhere01', SUBJECT, 1)).toEqual({-- moved: 0,-- slot: null,-- full: false,-- })-- store.close()-- })--})----describe('account merge (ADR-029)', () => {-- const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'-- it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', () => {-- const store = openSqliteStore(':memory:')-- // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.-- store.players.upsertMany([-- player('s0', SUBJECT, 0),-- player('f0', FROM, 0),-- player('f1', FROM, 1),-- player('f2', FROM, 2),-- ])-- expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5)).toEqual(-- { moved: 2, kept: 1, already: false },-- )-- expect(-- store.players-- .listByToken(SUBJECT)-- .map((p) => [p.id, p.charSlot])-- .sort(),-- ).toEqual([-- ['f1', 1],-- ['f2', 2],-- ['s0', 0],-- ])-- expect(store.players.listByToken(FROM).map((p) => p.id)).toEqual(['f0'])-- expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6)).toEqual(-- { moved: 0, kept: 0, already: true },-- )-- expect(store.players.listByToken(FROM).length).toBe(1)-- store.close()-- })--})----describe('account export and deletion (ADR-033)', () => {-- const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'-- it("exports the subject's characters without the token, then erases them and the alias's; structures stay unowned", () => {-- const store = openSqliteStore(':memory:')-- store.players.upsertMany([-- player('d0', SUBJECT, 0),-- player('d1', ALIAS, 0),-- player('o0', OTHER, 0),-- ])-- const entity = store.db.prepare(-- "INSERT INTO world_entities (id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at) VALUES (?, 'structure', 'hut', ?, 0, 0, 0, 0, 0, 0, 1, 'static', '{}', 1)",-- )-- entity.run('e1', 'd0')-- entity.run('e2', 'o0')-- store.db-- .prepare(-- "INSERT INTO identity_adoptions (adoption_key, subject_id, source, adopted_at) VALUES ('merge:mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', ?, 'merge', 1)",-- )-- .run(SUBJECT)-- const out = store.identity.exportSubject(SUBJECT)-- expect(out.characters.map((c) => c.id)).toEqual(['d0'])-- expect(out.characters[0]).not.toHaveProperty('token')-- expect(out.characters[0]?.skills).toEqual({ woodcutting: 10 })-- expect(out.structures.map((e) => e.id)).toEqual(['e1'])-- expect(store.identity.eraseSubjects([SUBJECT, ALIAS])).toEqual({-- characters: 2,-- structures_unowned: 1,-- identity_rows: 1,-- })-- expect(store.players.listByToken(SUBJECT)).toEqual([])-- expect(store.players.listByToken(ALIAS)).toEqual([])-- expect(store.players.listByToken(OTHER).map((p) => p.id)).toEqual(['o0'])-- expect(store.db.prepare('SELECT id, owner_id FROM world_entities ORDER BY id').all()).toEqual([-- { id: 'e1', owner_id: null },-- { id: 'e2', owner_id: 'o0' },-- ])-- expect(store.identity.eraseSubjects([SUBJECT])).toEqual({-- characters: 0,-- structures_unowned: 0,-- identity_rows: 0,-- })-- store.close()-- })--})----describe('player subject_id', () => {-- it('keeps subject_id through ordinary saves that do not carry it', () => {-- const store = openSqliteStore(':memory:')-- store.players.upsert(player('p1', SUBJECT, 0, { subjectId: SUBJECT }))-- const { subjectId: _drop, ...withoutSubject } = store.players.findById('p1')!-- store.players.upsert({ ...withoutSubject, name: 'renamed' })-- expect(store.players.findById('p1')).toMatchObject({ name: 'renamed', subjectId: SUBJECT })-- store.close()-- })--})----describe('mod repository', () => {-- it('stores installs, grants, placements and an append-only audit log', () => {-- const store = openSqliteStore(':memory:')-- store.mods.insert({-- id: 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0',-- name: 'Test',-- version: '1.0.0',-- target: 'games.browser',-- runtime: 'games-content@1',-- manifest: { a: 1 },-- pack: { props: [] },-- trustTier: 'unreviewed',-- status: 'disabled',-- installedBy: SUBJECT,-- installedAt: 1,-- updatedAt: 1,-- })-- const id = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'-- store.mods.upsertGrant({-- modId: id,-- capability: 'games.world.announce',-- grantedBy: SUBJECT,-- grantedAt: 2,-- revokedAt: null,-- revokedBy: null,-- })-- expect(store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 3)).toBe(true)-- expect(store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 4)).toBe(false)-- expect(store.mods.grants(id)[0]).toMatchObject({ revokedAt: 3, revokedBy: SUBJECT })---- store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e1', at: 5 })-- store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e2', at: 6 })-- expect(store.mods.placements(id)).toEqual([{ modId: id, key: 'bench', entityId: 'e2', at: 6 }])-- store.mods.deletePlacement(id, 'bench')-- expect(store.mods.placements(id)).toEqual([])---- store.mods.audit({-- modId: id,-- action: 'install',-- capability: null,-- actor: SUBJECT,-- detail: null,-- at: 1,-- })-- store.mods.audit({-- modId: id,-- action: 'grant',-- capability: 'games.world.announce',-- actor: SUBJECT,-- detail: { x: 1 },-- at: 2,-- })-- expect(store.mods.auditLog(id, 10).map((a) => a.action)).toEqual(['grant', 'install'])-- store.mods.setStatus(id, 'revoked', 9)-- expect(store.mods.get(id)).toMatchObject({-- status: 'revoked',-- updatedAt: 9,-- manifest: { a: 1 },-- })-- store.close()-- })--})----describe('media mirror queue', () => {-- it('queues once, schedules retries and records the Media object', () => {-- const store = openSqliteStore(':memory:')-- const asset = { assetHash: 'sha256-aa', fileName: 'sha256-aa.png', mime: 'image/png', bytes: 3 }-- expect(store.mediaMirrors.enqueue(asset, 10)).toBe(true)-- expect(store.mediaMirrors.enqueue(asset, 11)).toBe(false)-- expect(store.mediaMirrors.due(10, 5)).toHaveLength(1)-- store.mediaMirrors.markFailed('sha256-aa', 'http.503: down', 100, false, 12)-- expect(store.mediaMirrors.due(50, 5)).toHaveLength(0)-- expect(store.mediaMirrors.due(100, 5)).toHaveLength(1)-- store.mediaMirrors.noteObject('sha256-aa', 'med_X', 13)-- store.mediaMirrors.markMirrored('sha256-aa', 'med_X', 'https://openvibe.media/o/med_X', 14)-- expect(store.mediaMirrors.get('sha256-aa')).toMatchObject({-- status: 'mirrored',-- mediaId: 'med_X',-- attempts: 2,-- lastError: null,-- })-- expect(store.mediaMirrors.counts()).toEqual({ pending: 0, mirrored: 1, failed: 0 })-- store.close()-- })--})----describe('store transactions', () => {-- it('roll back every repository write together', () => {-- const dir = mkdtempSync(join(tmpdir(), 'openvibe-tx-'))-- const store = openSqliteStore(join(dir, 'world.db'))-- expect(() =>-- store.transaction(() => {-- store.players.upsert(player('p1', 'tok_12345678', 0))-- store.meta.set('env_time', '0.5')-- throw new Error('boom')-- }),-- ).toThrow('boom')-- expect(store.players.findById('p1')).toBeNull()-- expect(store.meta.get('env_time')).toBeNull()-- store.close()-- })--})ddeleted file mode 100644@@@ -1,431 +0,0 @@--import { createHash } from 'node:crypto'--import type Database from 'better-sqlite3'--import type {-- MediaMirrorDto,-- ModAuditDto,-- ModGrantDto,-- ModInstallDto,-- ModPlacementDto,-- ModStatus,-- ModTrustTier,--} from '../dto.js'--import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'----/**-- * SQLite repositories for the platform-integration tables: the adoption-- * ledger, the mod registry and the Media mirror queue. The game simulation-- * never touches these; apps/server's platform adapters do.-- */----export function createIdentityRepository(db: Database.Database): IdentityRepository {-- const takenSlots = db.prepare<[string], { char_slot: number }>(-- 'SELECT char_slot FROM players WHERE token = ?',-- )-- const rowsByToken = db.prepare<[string], { id: string; char_slot: number }>(-- 'SELECT id, char_slot FROM players WHERE token = ? ORDER BY char_slot',-- )-- const moveToSlot = db.prepare(-- 'UPDATE players SET token = ?, subject_id = ?, char_slot = ? WHERE id = ?',-- )-- const recordAdoption = db.prepare(`-- INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)-- VALUES (?, ?, ?, ?, ?, ?)-- ON CONFLICT(adoption_key) DO UPDATE SET-- moved = identity_adoptions.moved + excluded.moved,-- conflicts = excluded.conflicts-- `)-- const adopted = db.prepare<[string], { subject_id: string }>(-- 'SELECT subject_id FROM identity_adoptions WHERE adoption_key = ?',-- )---- const adoptGuest = db.transaction((guestToken: string, subjectId: string, now: number) => {-- // Once per guest token, remembered by a hash of it, never the token.-- const key = `guest:${createHash('sha256').update(guestToken).digest('hex').slice(0, 32)}`-- const none = { moved: 0, slot: null, full: false }-- if (adopted.get(key)) return none-- const rows = rowsByToken.all(guestToken)-- if (rows.length === 0) return none-- const taken = new Set(takenSlots.all(subjectId).map((r) => r.char_slot))-- let moved = 0-- let slot: number | null = null-- for (const row of rows) {-- const free = [0, 1, 2].find((s) => !taken.has(s))-- if (free === undefined) break-- moveToSlot.run(subjectId, subjectId, free, row.id)-- taken.add(free)-- slot ??= free-- moved++-- }-- if (moved === 0) return { moved: 0, slot: null, full: true }-- recordAdoption.run(key, subjectId, 'guest', moved, rows.length - moved, now)-- return { moved, slot, full: false }-- })---- // Account merge (ADR-029): the folded-in subject's characters join the survivor, once per merge.-- const mergeAccount = db.transaction(-- (from: string, into: string, mergeId: string, now: number) => {-- const key = `merge:${mergeId}`-- if (adopted.get(key)) return { moved: 0, kept: 0, already: true }-- const taken = new Set(takenSlots.all(into).map((r) => r.char_slot))-- const rows = rowsByToken.all(from)-- let moved = 0-- // First every character whose own slot is free there, then the rest into what is left.-- const clashing = rows.filter((row) => {-- if (taken.has(row.char_slot)) return true-- moveToSlot.run(into, into, row.char_slot, row.id)-- taken.add(row.char_slot)-- moved++-- return false-- })-- let kept = 0-- for (const row of clashing) {-- const slot = [0, 1, 2].find((s) => !taken.has(s))-- if (slot === undefined) {-- kept++-- continue-- }-- moveToSlot.run(into, into, slot, row.id)-- taken.add(slot)-- moved++-- }-- recordAdoption.run(key, into, 'merge', moved, kept, now)-- return { moved, kept, already: false }-- },-- )---- // Account export and deletion (ADR-033). A character row keyed by the subject (token) or carrying it (subject_id).-- const inList = (n: number): string => `(${Array.from({ length: n }, () => '?').join(',')})`-- const parsed = (row: Record<string, unknown>): Record<string, unknown> => {-- const out: Record<string, unknown> = {}-- for (const [k, v] of Object.entries(row)) {-- if (k === 'token') continue-- if (typeof v === 'string' && (v.startsWith('{') || v.startsWith('['))) {-- try {-- out[k] = JSON.parse(v)-- continue-- } catch {-- // kept as text-- }-- }-- out[k] = v-- }-- return out-- }-- const exportSubject = (subject: string) => {-- const characters = db-- .prepare('SELECT * FROM players WHERE token = ? OR subject_id = ? ORDER BY char_slot')-- .all(subject, subject) as Record<string, unknown>[]-- const ids = characters.map((c) => String(c.id))-- const structures = ids.length-- ? (db-- .prepare(`SELECT * FROM world_entities WHERE owner_id IN ${inList(ids.length)}`)-- .all(...ids) as Record<string, unknown>[])-- : []-- return { characters: characters.map(parsed), structures: structures.map(parsed) }-- }-- const eraseSubjects = db.transaction((subjects: string[]) => {-- const out = { characters: 0, structures_unowned: 0, identity_rows: 0 }-- if (!subjects.length) return out-- const q = inList(subjects.length)-- const ids = (-- db-- .prepare(`SELECT id FROM players WHERE token IN ${q} OR subject_id IN ${q}`)-- .all(...subjects, ...subjects) as { id: string }[]-- ).map((r) => r.id)-- if (ids.length) {-- out.structures_unowned = db-- .prepare(-- `UPDATE world_entities SET owner_id = NULL WHERE owner_id IN ${inList(ids.length)}`,-- )-- .run(...ids).changes-- out.characters = db-- .prepare(`DELETE FROM players WHERE id IN ${inList(ids.length)}`)-- .run(...ids).changes-- }-- out.identity_rows = db-- .prepare(`DELETE FROM identity_adoptions WHERE subject_id IN ${q}`)-- .run(...subjects).changes-- return out-- })---- return {-- exportSubject,-- eraseSubjects: (subjects) => eraseSubjects(subjects),-- mergeSubject: (from, into, mergeId, now) => mergeAccount(from, into, mergeId, now),-- adoptGuestCharacter: (guestToken, subjectId, now) => adoptGuest(guestToken, subjectId, now),-- }--}----interface ModRow {-- id: string-- name: string-- version: string-- target: string-- runtime: string-- manifest: string-- pack: string-- trust_tier: string-- status: string-- installed_by: string-- installed_at: number-- updated_at: number--}----interface GrantRow {-- mod_id: string-- capability: string-- granted_by: string-- granted_at: number-- revoked_at: number | null-- revoked_by: string | null--}----interface AuditRow {-- id: number-- mod_id: string-- action: string-- capability: string | null-- actor: string-- detail: string | null-- at: number--}----interface PlacementRow {-- mod_id: string-- placement_key: string-- entity_id: string | null-- at: number--}----const rowToMod = (r: ModRow): ModInstallDto => ({-- id: r.id,-- name: r.name,-- version: r.version,-- target: r.target,-- runtime: r.runtime,-- manifest: JSON.parse(r.manifest) as Record<string, unknown>,-- pack: JSON.parse(r.pack) as Record<string, unknown>,-- trustTier: r.trust_tier as ModTrustTier,-- status: r.status as ModStatus,-- installedBy: r.installed_by,-- installedAt: r.installed_at,-- updatedAt: r.updated_at,--})----const rowToGrant = (r: GrantRow): ModGrantDto => ({-- modId: r.mod_id,-- capability: r.capability,-- grantedBy: r.granted_by,-- grantedAt: r.granted_at,-- revokedAt: r.revoked_at,-- revokedBy: r.revoked_by,--})----export function createModRepository(db: Database.Database): ModRepository {-- const insertMod = db.prepare(`-- INSERT INTO mods (id, name, version, target, runtime, manifest, pack, trust_tier, status, installed_by, installed_at, updated_at)-- VALUES (@id, @name, @version, @target, @runtime, @manifest, @pack, @trust_tier, @status, @installed_by, @installed_at, @updated_at)-- `)-- const upsertGrant = db.prepare(`-- INSERT INTO mod_grants (mod_id, capability, granted_by, granted_at, revoked_at, revoked_by)-- VALUES (@mod_id, @capability, @granted_by, @granted_at, @revoked_at, @revoked_by)-- ON CONFLICT(mod_id, capability) DO UPDATE SET-- granted_by = excluded.granted_by, granted_at = excluded.granted_at,-- revoked_at = excluded.revoked_at, revoked_by = excluded.revoked_by-- `)-- const insertAudit = db.prepare(`-- INSERT INTO mod_audit (mod_id, action, capability, actor, detail, at)-- VALUES (?, ?, ?, ?, ?, ?)-- `)-- const setPlacement = db.prepare(`-- INSERT INTO mod_placements (mod_id, placement_key, entity_id, at) VALUES (?, ?, ?, ?)-- ON CONFLICT(mod_id, placement_key) DO UPDATE SET entity_id = excluded.entity_id, at = excluded.at-- `)---- return {-- list() {-- return (db.prepare('SELECT * FROM mods ORDER BY installed_at, id').all() as ModRow[]).map(-- rowToMod,-- )-- },-- get(id) {-- const row = db.prepare('SELECT * FROM mods WHERE id = ?').get(id) as ModRow | undefined-- return row ? rowToMod(row) : null-- },-- insert(mod) {-- insertMod.run({-- id: mod.id,-- name: mod.name,-- version: mod.version,-- target: mod.target,-- runtime: mod.runtime,-- manifest: JSON.stringify(mod.manifest),-- pack: JSON.stringify(mod.pack),-- trust_tier: mod.trustTier,-- status: mod.status,-- installed_by: mod.installedBy,-- installed_at: mod.installedAt,-- updated_at: mod.updatedAt,-- })-- },-- setStatus(id, status, at) {-- db.prepare('UPDATE mods SET status = ?, updated_at = ? WHERE id = ?').run(status, at, id)-- },-- grants(modId) {-- return (-- db-- .prepare('SELECT * FROM mod_grants WHERE mod_id = ? ORDER BY capability')-- .all(modId) as GrantRow[]-- ).map(rowToGrant)-- },-- upsertGrant(g) {-- upsertGrant.run({-- mod_id: g.modId,-- capability: g.capability,-- granted_by: g.grantedBy,-- granted_at: g.grantedAt,-- revoked_at: g.revokedAt,-- revoked_by: g.revokedBy,-- })-- },-- revokeGrant(modId, capability, by, at) {-- const r = db-- .prepare(-- 'UPDATE mod_grants SET revoked_at = ?, revoked_by = ? WHERE mod_id = ? AND capability = ? AND revoked_at IS NULL',-- )-- .run(at, by, modId, capability)-- return r.changes > 0-- },-- audit(e) {-- insertAudit.run(-- e.modId,-- e.action,-- e.capability,-- e.actor,-- e.detail ? JSON.stringify(e.detail) : null,-- e.at,-- )-- },-- auditLog(modId, limit) {-- return (-- db-- .prepare('SELECT * FROM mod_audit WHERE mod_id = ? ORDER BY id DESC LIMIT ?')-- .all(modId, limit) as AuditRow[]-- ).map((r): ModAuditDto => ({-- id: r.id,-- modId: r.mod_id,-- action: r.action,-- capability: r.capability,-- actor: r.actor,-- detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : null,-- at: r.at,-- }))-- },-- placements(modId) {-- return (-- db-- .prepare('SELECT * FROM mod_placements WHERE mod_id = ? ORDER BY placement_key')-- .all(modId) as PlacementRow[]-- ).map((r): ModPlacementDto => ({-- modId: r.mod_id,-- key: r.placement_key,-- entityId: r.entity_id,-- at: r.at,-- }))-- },-- setPlacement(p) {-- setPlacement.run(p.modId, p.key, p.entityId, p.at)-- },-- deletePlacement(modId, key) {-- db.prepare('DELETE FROM mod_placements WHERE mod_id = ? AND placement_key = ?').run(-- modId,-- key,-- )-- },-- }--}----interface MirrorRow {-- asset_hash: string-- file_name: string-- mime: string-- bytes: number-- status: string-- media_id: string | null-- public_url: string | null-- attempts: number-- last_error: string | null-- next_attempt_at: number-- updated_at: number--}----const rowToMirror = (r: MirrorRow): MediaMirrorDto => ({-- assetHash: r.asset_hash,-- fileName: r.file_name,-- mime: r.mime,-- bytes: r.bytes,-- status: r.status as MediaMirrorDto['status'],-- mediaId: r.media_id,-- publicUrl: r.public_url,-- attempts: r.attempts,-- lastError: r.last_error,-- nextAttemptAt: r.next_attempt_at,-- updatedAt: r.updated_at,--})----export function createMediaMirrorRepository(db: Database.Database): MediaMirrorRepository {-- return {-- enqueue(asset, now) {-- const r = db-- .prepare(-- `INSERT OR IGNORE INTO media_mirrors (asset_hash, file_name, mime, bytes, status, attempts, next_attempt_at, updated_at)-- VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`,-- )-- .run(asset.assetHash, asset.fileName, asset.mime, asset.bytes, now, now)-- return r.changes > 0-- },-- get(assetHash) {-- const row = db.prepare('SELECT * FROM media_mirrors WHERE asset_hash = ?').get(assetHash) as-- MirrorRow | undefined-- return row ? rowToMirror(row) : null-- },-- due(now, limit) {-- return (-- db-- .prepare(-- `SELECT * FROM media_mirrors WHERE status = 'pending' AND next_attempt_at <= ?-- ORDER BY next_attempt_at, asset_hash LIMIT ?`,-- )-- .all(now, limit) as MirrorRow[]-- ).map(rowToMirror)-- },-- noteObject(assetHash, mediaId, now) {-- db.prepare('UPDATE media_mirrors SET media_id = ?, updated_at = ? WHERE asset_hash = ?').run(-- mediaId,-- now,-- assetHash,-- )-- },-- markMirrored(assetHash, mediaId, publicUrl, now) {-- db.prepare(-- `UPDATE media_mirrors SET status = 'mirrored', media_id = ?, public_url = ?, last_error = NULL,-- attempts = attempts + 1, updated_at = ? WHERE asset_hash = ?`,-- ).run(mediaId, publicUrl, now, assetHash)-- },-- markFailed(assetHash, error, nextAttemptAt, terminal, now) {-- db.prepare(-- `UPDATE media_mirrors SET status = ?, last_error = ?, attempts = attempts + 1,-- next_attempt_at = ?, updated_at = ? WHERE asset_hash = ?`,-- ).run(terminal ? 'failed' : 'pending', error.slice(0, 500), nextAttemptAt, now, assetHash)-- },-- counts() {-- const out: Record<MediaMirrorDto['status'], number> = { pending: 0, mirrored: 0, failed: 0 }-- for (const r of db-- .prepare('SELECT status, COUNT(*) AS n FROM media_mirrors GROUP BY status')-- .all() as { status: MediaMirrorDto['status']; n: number }[]) {-- out[r.status] = r.n-- }-- return out-- },-- }--}ddeleted file mode 100644@@@ -1,183 +0,0 @@--import { describe, expect, it } from 'vitest'--import type { PlayerDto, WorldEntityDto } from '../dto.js'--import { openSqliteStore } from './sqliteStore.js'----function makeEntity(id: string): WorldEntityDto {-- return {-- id,-- kind: 'prop',-- defId: 'wooden_crate',-- ownerId: 'player1',-- pos: [1, 2, 3],-- rot: [0, 0, 0, 1],-- motion: 'dynamic',-- state: null,-- updatedAt: 1000,-- }--}----describe('sqlite store', () => {-- it('roundtrips world entities with batch upsert', () => {-- const store = openSqliteStore(':memory:')-- store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])-- const loaded = store.worldEntities.loadAll()-- expect(loaded).toHaveLength(2)-- expect(loaded.find((e) => e.id === 'a')).toEqual(makeEntity('a'))---- // update in place-- const moved = {-- ...makeEntity('a'),-- pos: [9, 9, 9] as [number, number, number],-- motion: 'frozen' as const,-- }-- store.worldEntities.upsertMany([moved])-- expect(store.worldEntities.loadAll().find((e) => e.id === 'a')).toEqual(moved)---- store.worldEntities.deleteMany(['a'])-- expect(store.worldEntities.loadAll()).toHaveLength(1)-- store.close()-- })---- it('roundtrips players by token including inventory json', () => {-- const store = openSqliteStore(':memory:')-- const player: PlayerDto = {-- id: 'p1',-- token: 'tok_12345678',-- name: 'Scrapper',-- pos: [0, 1, 0],-- yaw: 1.5,-- armor: { defId: 'padded_jacket', count: 1, meta: { dur: 12 } },-- reputation: { openvibeville: 12, rustjaw: -30 },-- unlocks: ['craft_still'],-- activeJob: { job: 'lumber_run', progress: 3 },-- inventory: {-- size: 24,-- hotbar: 6,-- slots: [{ i: 0, stack: { defId: 'wood_plank', count: 10 } }],-- },-- skills: { woodcutting: 120, mining: 40 },-- friends: ['p2', 'p3'],-- stats: { health: 90, hunger: 80, thirst: 70, stamina: 100 },-- charSlot: 0,-- appearance: {-- body: 'female',-- skin: 3,-- hairStyle: 'bun',-- hairColor: 2,-- facialHair: 'none',-- top: 1,-- bottom: 2,-- shoes: 3,-- height: 1.02,-- build: 0.95,-- },-- updatedAt: 2000,-- }-- store.players.upsert(player)-- expect(store.players.findByToken('tok_12345678')).toEqual(player)-- expect(store.players.findByToken('nope')).toBeNull()-- store.close()-- })---- it('roundtrips constraints', () => {-- const store = openSqliteStore(':memory:')-- store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])-- const params = {-- anchorA: [0.5, 0, 0],-- anchorB: [-0.5, 0, 0],-- axisA: [0, 1, 0],-- axisB: [0, 1, 0],-- limits: { min: -1, max: 1 },-- }-- store.constraints.upsertMany([-- { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },-- { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },-- ])-- expect(store.constraints.loadAll()).toEqual([-- { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },-- { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },-- ])-- store.constraints.deleteMany(['c1', 'c2'])-- expect(store.constraints.loadAll()).toEqual([])-- store.close()-- })---- it('migrates a v1 database to the current schema', async () => {-- const { default: Database } = await import('better-sqlite3')-- const { mkdtempSync } = await import('node:fs')-- const { tmpdir } = await import('node:os')-- const { join } = await import('node:path')-- const dir = mkdtempSync(join(tmpdir(), 'openvibe-migrate-'))-- const path = join(dir, 'v1.db')---- // Build a genuine v1 database (no skills column, no constraints table).-- const v1 = new Database(path)-- v1.exec(`-- CREATE TABLE world_entities (-- id TEXT PRIMARY KEY, kind TEXT NOT NULL, def_id TEXT NOT NULL, owner_id TEXT,-- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,-- rot_x REAL NOT NULL, rot_y REAL NOT NULL, rot_z REAL NOT NULL, rot_w REAL NOT NULL,-- motion TEXT NOT NULL, state TEXT, updated_at INTEGER NOT NULL-- );-- CREATE TABLE players (-- id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL,-- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL, yaw REAL NOT NULL,-- inventory TEXT NOT NULL, updated_at INTEGER NOT NULL-- );-- CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);-- INSERT INTO meta VALUES ('schema_version', '1');-- INSERT INTO players VALUES ('p1', 'tok_11111111', 'Old Scrapper', 1, 2, 3, 0.5,-- '{"size":24,"hotbar":6,"slots":[]}', 999);-- `)-- v1.close()---- const store = openSqliteStore(path)-- const player = store.players.findByToken('tok_11111111')-- expect(player?.name).toBe('Old Scrapper')-- expect(player?.skills).toEqual({})-- expect(player?.friends).toEqual([])-- expect(player?.appearance).toBeNull()-- // constraints table exists and works post-migration (incl. the params-- // column added by a later step)-- store.constraints.upsertMany([-- { id: 'c1', type: 'weld', entityA: 'x', entityB: 'y', params: null, updatedAt: 1 },-- {-- id: 'c2',-- type: 'rope',-- entityA: 'x',-- entityB: 'y',-- params: { length: 2 },-- updatedAt: 1,-- },-- ])-- const loaded = store.constraints.loadAll()-- expect(loaded).toHaveLength(2)-- expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })-- expect(store.meta.get('schema_version')).toBe('11')-- // The armor column round-trips.-- store.players.upsert({-- ...store.players.findByToken('tok_11111111')!,-- armor: { defId: 'padded_jacket', count: 1, meta: { dur: 5 } },-- })-- expect(store.players.findByToken('tok_11111111')?.armor).toEqual({-- defId: 'padded_jacket',-- count: 1,-- meta: { dur: 5 },-- })-- store.close()-- })---- it('persists resource state json', () => {-- const store = openSqliteStore(':memory:')-- const node: WorldEntityDto = {-- ...makeEntity('r1'),-- kind: 'resource',-- defId: 'scrap_metal',-- motion: 'static',-- state: { remaining: 12, perUse: 2 },-- }-- store.worldEntities.upsertMany([node])-- expect(store.worldEntities.loadAll()[0]?.state).toEqual({ remaining: 12, perUse: 2 })-- store.close()-- })--})ddeleted file mode 100644@@@ -1,530 +0,0 @@--import Database from 'better-sqlite3'--import type { InventoryDto, SkillsDto } from '@openvibe/gameplay'--import type { ConstraintDto, PlayerDto, WorldEntityDto } from '../dto.js'--import type {-- ConstraintRepository,-- MetaRepository,-- PersistenceStore,-- PlayerRepository,-- WorldEntityRepository,--} from '../repositories.js'--import {-- createIdentityRepository,-- createMediaMirrorRepository,-- createModRepository,--} from './platformRepositories.js'----/**-- * The SQLite store plus its database handle. Only apps/server's platform-- * adapters use `db` directly — for the event outbox, whose rows must commit-- * in the same transaction as the game state they describe.-- */--export interface SqlitePersistenceStore extends PersistenceStore {-- readonly db: Database.Database--}----/**-- * SQLite implementation. Synchronous better-sqlite3 is intentional: batched-- * transactional writes from the flush system are microseconds-scale and far-- * simpler to reason about than async write queues.-- *-- * Schema changes are forward-only migrations keyed off meta.schema_version;-- * a database is upgraded step by step inside a transaction per step.-- */----const SCHEMA_VERSION = 11----const BASE_SCHEMA = `--CREATE TABLE IF NOT EXISTS world_entities (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL,-- def_id TEXT NOT NULL,-- owner_id TEXT,-- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,-- rot_x REAL NOT NULL, rot_y REAL NOT NULL, rot_z REAL NOT NULL, rot_w REAL NOT NULL,-- motion TEXT NOT NULL,-- state TEXT,-- updated_at INTEGER NOT NULL--);--CREATE TABLE IF NOT EXISTS players (-- id TEXT PRIMARY KEY,-- token TEXT NOT NULL,-- char_slot INTEGER NOT NULL DEFAULT 0,-- name TEXT NOT NULL,-- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,-- yaw REAL NOT NULL,-- inventory TEXT NOT NULL,-- skills TEXT NOT NULL DEFAULT '{}',-- friends TEXT NOT NULL DEFAULT '[]',-- appearance TEXT,-- stats TEXT,-- armor TEXT,-- reputation TEXT NOT NULL DEFAULT '{}',-- unlocks TEXT NOT NULL DEFAULT '[]',-- active_job TEXT,-- subject_id TEXT,-- updated_at INTEGER NOT NULL--);--CREATE UNIQUE INDEX IF NOT EXISTS idx_players_token_slot ON players (token, char_slot);--CREATE TABLE IF NOT EXISTS constraints (-- id TEXT PRIMARY KEY,-- type TEXT NOT NULL,-- entity_a TEXT NOT NULL,-- entity_b TEXT NOT NULL,-- params TEXT,-- updated_at INTEGER NOT NULL--);--CREATE TABLE IF NOT EXISTS meta (-- key TEXT PRIMARY KEY,-- value TEXT NOT NULL--);--`----/**-- * Platform integration tables: canonical subject accounts, the adoption-- * ledger, the mod registry and the Media mirror queue. Shared by the-- * fresh-database path and the migration that adds them so both end up-- * identical.-- */--const PLATFORM_SCHEMA = `--CREATE INDEX IF NOT EXISTS idx_players_subject ON players (subject_id);--CREATE TABLE IF NOT EXISTS identity_adoptions (-- adoption_key TEXT PRIMARY KEY,-- subject_id TEXT NOT NULL,-- source TEXT NOT NULL,-- moved INTEGER NOT NULL DEFAULT 0,-- conflicts INTEGER NOT NULL DEFAULT 0,-- adopted_at INTEGER NOT NULL--);--CREATE TABLE IF NOT EXISTS mods (-- id TEXT PRIMARY KEY,-- name TEXT NOT NULL,-- version TEXT NOT NULL,-- target TEXT NOT NULL,-- runtime TEXT NOT NULL,-- manifest TEXT NOT NULL,-- pack TEXT NOT NULL,-- trust_tier TEXT NOT NULL,-- status TEXT NOT NULL,-- installed_by TEXT NOT NULL,-- installed_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);--CREATE TABLE IF NOT EXISTS mod_grants (-- mod_id TEXT NOT NULL,-- capability TEXT NOT NULL,-- granted_by TEXT NOT NULL,-- granted_at INTEGER NOT NULL,-- revoked_at INTEGER,-- revoked_by TEXT,-- PRIMARY KEY (mod_id, capability)--);--CREATE TABLE IF NOT EXISTS mod_placements (-- mod_id TEXT NOT NULL,-- placement_key TEXT NOT NULL,-- entity_id TEXT,-- at INTEGER NOT NULL,-- PRIMARY KEY (mod_id, placement_key)--);--CREATE TABLE IF NOT EXISTS mod_audit (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- mod_id TEXT NOT NULL,-- action TEXT NOT NULL,-- capability TEXT,-- actor TEXT NOT NULL,-- detail TEXT,-- at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS idx_mod_audit_mod ON mod_audit (mod_id, id);--CREATE TABLE IF NOT EXISTS media_mirrors (-- asset_hash TEXT PRIMARY KEY,-- file_name TEXT NOT NULL,-- mime TEXT NOT NULL,-- bytes INTEGER NOT NULL,-- status TEXT NOT NULL,-- media_id TEXT,-- public_url TEXT,-- attempts INTEGER NOT NULL DEFAULT 0,-- last_error TEXT,-- next_attempt_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL--);--CREATE INDEX IF NOT EXISTS idx_media_mirrors_due ON media_mirrors (status, next_attempt_at);--`----/** Migration from version N applies index N-1. Each runs in a transaction. */--const MIGRATIONS: Record<number, (db: Database.Database) => void> = {-- 1: (db) => {-- db.exec(`-- ALTER TABLE players ADD COLUMN skills TEXT NOT NULL DEFAULT '{}';-- CREATE TABLE IF NOT EXISTS constraints (-- id TEXT PRIMARY KEY,-- type TEXT NOT NULL,-- entity_a TEXT NOT NULL,-- entity_b TEXT NOT NULL,-- updated_at INTEGER NOT NULL-- );-- `)-- },-- 2: (db) => {-- db.exec("ALTER TABLE players ADD COLUMN friends TEXT NOT NULL DEFAULT '[]';")-- },-- 3: (db) => {-- db.exec('ALTER TABLE players ADD COLUMN appearance TEXT;')-- },-- 4: (db) => {-- db.exec('ALTER TABLE players ADD COLUMN stats TEXT;')-- },-- 5: (db) => {-- // Multi-character accounts: token is no longer unique — (token, slot)-- // is. SQLite can't drop a UNIQUE constraint, so rebuild the table.-- db.exec(`-- ALTER TABLE players RENAME TO players_old;-- CREATE TABLE players (-- id TEXT PRIMARY KEY,-- token TEXT NOT NULL,-- char_slot INTEGER NOT NULL DEFAULT 0,-- name TEXT NOT NULL,-- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,-- yaw REAL NOT NULL,-- inventory TEXT NOT NULL,-- skills TEXT NOT NULL DEFAULT '{}',-- friends TEXT NOT NULL DEFAULT '[]',-- appearance TEXT,-- stats TEXT,-- updated_at INTEGER NOT NULL-- );-- INSERT INTO players (id, token, char_slot, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, updated_at)-- SELECT id, token, 0, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, updated_at FROM players_old;-- DROP TABLE players_old;-- CREATE UNIQUE INDEX idx_players_token_slot ON players (token, char_slot);-- `)-- },-- 6: (db) => {-- // Sandbox constraint set: type-specific parameters (anchors, axes,-- // lengths, limits, motor). Existing weld rows carry NULL params.-- db.exec('ALTER TABLE constraints ADD COLUMN params TEXT;')-- },-- 7: (db) => {-- // Worn armor (one slot; durability rides in the stack meta).-- db.exec('ALTER TABLE players ADD COLUMN armor TEXT;')-- },-- 8: (db) => {-- // Faction reputation scores.-- db.exec("ALTER TABLE players ADD COLUMN reputation TEXT NOT NULL DEFAULT '{}';")-- },-- 9: (db) => {-- // Blueprint unlocks + active contract.-- db.exec(`-- ALTER TABLE players ADD COLUMN unlocks TEXT NOT NULL DEFAULT '[]';-- ALTER TABLE players ADD COLUMN active_job TEXT;-- `)-- },-- 10: (db) => {-- // Platform integration: canonical subject per account (ADR-0006), the-- // adoption ledger, the mod registry and the Media mirror queue.-- db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')-- db.exec(PLATFORM_SCHEMA)-- },--}----interface WorldEntityRow {-- id: string-- kind: string-- def_id: string-- owner_id: string | null-- pos_x: number-- pos_y: number-- pos_z: number-- rot_x: number-- rot_y: number-- rot_z: number-- rot_w: number-- motion: string-- state: string | null-- updated_at: number--}----interface PlayerRow {-- id: string-- token: string-- name: string-- pos_x: number-- pos_y: number-- pos_z: number-- yaw: number-- inventory: string-- skills: string-- friends: string-- appearance: string | null-- stats: string | null-- armor: string | null-- reputation: string-- unlocks: string-- active_job: string | null-- subject_id: string | null-- char_slot: number-- updated_at: number--}----interface ConstraintRow {-- id: string-- type: string-- entity_a: string-- entity_b: string-- params: string | null-- updated_at: number--}----export function openSqliteStore(path: string): SqlitePersistenceStore {-- const db = new Database(path)-- db.pragma('journal_mode = WAL')-- db.pragma('synchronous = NORMAL')---- const hasMeta = db-- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='meta'")-- .get()-- if (!hasMeta) {-- // Fresh database: create the full current schema.-- db.exec(BASE_SCHEMA)-- db.exec(PLATFORM_SCHEMA)-- db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(-- 'schema_version',-- String(SCHEMA_VERSION),-- )-- } else {-- let version = Number(-- (-- db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as-- { value: string } | undefined-- )?.value ?? '1',-- )-- if (version > SCHEMA_VERSION) {-- throw new Error(`database schema ${version} is newer than this build (${SCHEMA_VERSION})`)-- }-- while (version < SCHEMA_VERSION) {-- const migrate = MIGRATIONS[version]-- if (!migrate) throw new Error(`missing migration from schema version ${version}`)-- db.transaction(() => {-- migrate(db)-- db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(-- 'schema_version',-- String(version + 1),-- )-- })()-- version++-- }-- }---- const metaGet = db.prepare<[string], { value: string }>('SELECT value FROM meta WHERE key = ?')-- const metaSet = db.prepare(-- 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',-- )---- const upsertEntity = db.prepare(`-- INSERT INTO world_entities (id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at)-- VALUES (@id, @kind, @def_id, @owner_id, @pos_x, @pos_y, @pos_z, @rot_x, @rot_y, @rot_z, @rot_w, @motion, @state, @updated_at)-- ON CONFLICT(id) DO UPDATE SET-- pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,-- rot_x=excluded.rot_x, rot_y=excluded.rot_y, rot_z=excluded.rot_z, rot_w=excluded.rot_w,-- motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at-- `)-- const deleteEntity = db.prepare('DELETE FROM world_entities WHERE id = ?')-- const selectEntities = db.prepare<[], WorldEntityRow>('SELECT * FROM world_entities')---- const worldEntities: WorldEntityRepository = {-- loadAll(): WorldEntityDto[] {-- return selectEntities.all().map((row) => ({-- id: row.id,-- kind: row.kind as WorldEntityDto['kind'],-- defId: row.def_id,-- ownerId: row.owner_id,-- pos: [row.pos_x, row.pos_y, row.pos_z],-- rot: [row.rot_x, row.rot_y, row.rot_z, row.rot_w],-- motion: row.motion as WorldEntityDto['motion'],-- state: row.state ? (JSON.parse(row.state) as WorldEntityDto['state']) : null,-- updatedAt: row.updated_at,-- }))-- },-- upsertMany: db.transaction((entities: readonly WorldEntityDto[]) => {-- for (const e of entities) {-- upsertEntity.run({-- id: e.id,-- kind: e.kind,-- def_id: e.defId,-- owner_id: e.ownerId,-- pos_x: e.pos[0],-- pos_y: e.pos[1],-- pos_z: e.pos[2],-- rot_x: e.rot[0],-- rot_y: e.rot[1],-- rot_z: e.rot[2],-- rot_w: e.rot[3],-- motion: e.motion,-- state: e.state ? JSON.stringify(e.state) : null,-- updated_at: e.updatedAt,-- })-- }-- }) as (entities: readonly WorldEntityDto[]) => void,-- deleteMany: db.transaction((ids: readonly string[]) => {-- for (const id of ids) deleteEntity.run(id)-- }) as (ids: readonly string[]) => void,-- deleteByKind(kind: string): void {-- db.prepare('DELETE FROM world_entities WHERE kind = ?').run(kind)-- },-- }---- const upsertPlayer = db.prepare(`-- INSERT INTO players (id, token, char_slot, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, armor, reputation, unlocks, active_job, subject_id, updated_at)-- VALUES (@id, @token, @char_slot, @name, @pos_x, @pos_y, @pos_z, @yaw, @inventory, @skills, @friends, @appearance, @stats, @armor, @reputation, @unlocks, @active_job, @subject_id, @updated_at)-- ON CONFLICT(id) DO UPDATE SET-- subject_id=COALESCE(excluded.subject_id, players.subject_id),-- name=excluded.name, pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,-- yaw=excluded.yaw, inventory=excluded.inventory, skills=excluded.skills, friends=excluded.friends, appearance=excluded.appearance, stats=excluded.stats, armor=excluded.armor, reputation=excluded.reputation, unlocks=excluded.unlocks, active_job=excluded.active_job, updated_at=excluded.updated_at-- `)-- const selectPlayerByToken = db.prepare<[string], PlayerRow>(-- 'SELECT * FROM players WHERE token = ?',-- )-- const selectPlayerById = db.prepare<[string], PlayerRow>('SELECT * FROM players WHERE id = ?')---- const rowToPlayer = (row: PlayerRow): PlayerDto => ({-- id: row.id,-- token: row.token,-- name: row.name,-- pos: [row.pos_x, row.pos_y, row.pos_z],-- yaw: row.yaw,-- inventory: JSON.parse(row.inventory) as InventoryDto,-- skills: JSON.parse(row.skills || '{}') as SkillsDto,-- friends: JSON.parse(row.friends || '[]') as string[],-- appearance: row.appearance ? (JSON.parse(row.appearance) as PlayerDto['appearance']) : null,-- armor: row.armor ? (JSON.parse(row.armor) as PlayerDto['armor']) : null,-- reputation: row.reputation ? (JSON.parse(row.reputation) as PlayerDto['reputation']) : {},-- unlocks: row.unlocks ? (JSON.parse(row.unlocks) as string[]) : [],-- activeJob: row.active_job ? (JSON.parse(row.active_job) as PlayerDto['activeJob']) : null,-- stats: row.stats ? (JSON.parse(row.stats) as PlayerDto['stats']) : null,-- charSlot: row.char_slot ?? 0,-- ...(row.subject_id ? { subjectId: row.subject_id } : {}),-- updatedAt: row.updated_at,-- })---- const playerToRow = (p: PlayerDto) => ({-- id: p.id,-- token: p.token,-- name: p.name,-- pos_x: p.pos[0],-- pos_y: p.pos[1],-- pos_z: p.pos[2],-- yaw: p.yaw,-- inventory: JSON.stringify(p.inventory),-- skills: JSON.stringify(p.skills),-- friends: JSON.stringify(p.friends),-- appearance: p.appearance ? JSON.stringify(p.appearance) : null,-- stats: p.stats ? JSON.stringify(p.stats) : null,-- armor: p.armor ? JSON.stringify(p.armor) : null,-- reputation: JSON.stringify(p.reputation ?? {}),-- unlocks: JSON.stringify(p.unlocks ?? []),-- active_job: p.activeJob ? JSON.stringify(p.activeJob) : null,-- char_slot: p.charSlot ?? 0,-- subject_id: p.subjectId ?? null,-- updated_at: p.updatedAt,-- })---- const players: PlayerRepository = {-- listByToken(token: string): PlayerDto[] {-- const rows = db-- .prepare('SELECT * FROM players WHERE token = ? ORDER BY char_slot')-- .all(token) as PlayerRow[]-- return rows.map(rowToPlayer)-- },-- findByTokenSlot(token: string, slot: number): PlayerDto | null {-- const row = db-- .prepare('SELECT * FROM players WHERE token = ? AND char_slot = ?')-- .get(token, slot) as PlayerRow | undefined-- return row ? rowToPlayer(row) : null-- },-- findByToken(token: string): PlayerDto | null {-- const row = selectPlayerByToken.get(token)-- return row ? rowToPlayer(row) : null-- },-- findById(id: string): PlayerDto | null {-- const row = selectPlayerById.get(id)-- return row ? rowToPlayer(row) : null-- },-- upsert(player: PlayerDto): void {-- upsertPlayer.run(playerToRow(player))-- },-- upsertMany: db.transaction((list: readonly PlayerDto[]) => {-- for (const p of list) upsertPlayer.run(playerToRow(p))-- }) as (list: readonly PlayerDto[]) => void,-- resetAllPositions(pos: [number, number, number], yaw: number): void {-- db.prepare('UPDATE players SET pos_x = ?, pos_y = ?, pos_z = ?, yaw = ?').run(-- pos[0],-- pos[1],-- pos[2],-- yaw,-- )-- },-- }---- const upsertConstraint = db.prepare(`-- INSERT INTO constraints (id, type, entity_a, entity_b, params, updated_at)-- VALUES (@id, @type, @entity_a, @entity_b, @params, @updated_at)-- ON CONFLICT(id) DO UPDATE SET params=excluded.params, updated_at=excluded.updated_at-- `)-- const deleteConstraint = db.prepare('DELETE FROM constraints WHERE id = ?')-- const selectConstraints = db.prepare<[], ConstraintRow>('SELECT * FROM constraints')---- const constraints: ConstraintRepository = {-- loadAll(): ConstraintDto[] {-- return selectConstraints.all().map((row) => ({-- id: row.id,-- type: row.type,-- entityA: row.entity_a,-- entityB: row.entity_b,-- params: row.params ? (JSON.parse(row.params) as Record<string, unknown>) : null,-- updatedAt: row.updated_at,-- }))-- },-- upsertMany: db.transaction((list: readonly ConstraintDto[]) => {-- for (const c of list) {-- upsertConstraint.run({-- id: c.id,-- type: c.type,-- entity_a: c.entityA,-- entity_b: c.entityB,-- params: c.params ? JSON.stringify(c.params) : null,-- updated_at: c.updatedAt,-- })-- }-- }) as (list: readonly ConstraintDto[]) => void,-- deleteMany: db.transaction((ids: readonly string[]) => {-- for (const id of ids) deleteConstraint.run(id)-- }) as (ids: readonly string[]) => void,-- }---- const meta: MetaRepository = {-- get(key: string): string | null {-- return metaGet.get(key)?.value ?? null-- },-- set(key: string, value: string): void {-- metaSet.run(key, value)-- },-- }---- return {-- db,-- worldEntities,-- players,-- constraints,-- meta,-- identity: createIdentityRepository(db),-- mods: createModRepository(db),-- mediaMirrors: createMediaMirrorRepository(db),-- transaction<T>(fn: () => T): T {-- return db.transaction(fn)()-- },-- close(): void {-- db.close()-- },-- }--}nnew file mode 100644@@@ -0,0 +1,55 @@++import { PGlite } from '@electric-sql/pglite'++import { createDb, type Db } from 'openvibe-sdk/db'++import { openPgStore, type PgPersistenceStore } from './pg/pgStore.js'++import { MIGRATIONS_DIR } from './migrations.js'++++/**++ * Test helpers: one in-memory PGlite database per test process, with the Games migrations applied++ * once, so what the tests exercise is the real PostgreSQL schema and the real openvibe-sdk/db layer.++ * Starting PGlite (WASM) is expensive, so the instance is shared and emptied between tests instead of++ * recreated; each `openTestStore()` sees an empty schema.++ */++export { MIGRATIONS_DIR }++++const TABLES = [++ 'places',++ 'world_meta',++ 'world_entities',++ 'world_constraints',++ 'characters',++ 'mods',++ 'mod_grants',++ 'mod_placements',++ 'mod_audit',++ 'media_mirrors',++ 'identity_adoptions',++ 'account_data_events',++ 'event_outbox',++ 'token_revocations',++].join(', ')++++let shared: Db | null = null++++async function ensureDb(): Promise<Db> {++ if (!shared) {++ const pg = new PGlite()++ const db = createDb({ pglite: pg, service: 'games-test' })++ await db.migrate({ dir: MIGRATIONS_DIR })++ shared = db++ }++ await shared.query(`TRUNCATE ${TABLES} RESTART IDENTITY CASCADE`)++ return shared++}++++/** The shared, freshly emptied in-memory database (do not close it). */++export async function openTestDb(): Promise<Db> {++ return ensureDb()++}++++/** A store over the shared, freshly emptied database; its `close()` is a no-op (the instance is shared). */++export async function openTestStore(opts: { placeId?: string } = {}): Promise<PgPersistenceStore> {++ const db = await ensureDb()++ const store = openPgStore(db, opts)++ await store.ensurePlace()++ return { ...store, close: async () => {} }++}@@@ -1,11 +1,10 @@lockfileVersion: '9.0'settings:-- autoInstallPeers: true++ autoInstallPeers: falseexcludeLinksFromLockfile: falseimporters:--.:devDependencies:'@eslint/js':@@@ -87,6 +86,9 @@ importers:'@babylonjs/havok':specifier: ^1.3.10version: 1.3.14++ '@electric-sql/pglite':++ specifier: ^0.5.8++ version: 0.5.8'@openvibe/content':specifier: workspace:*version: link:../../packages/content@@@ -108,18 +110,21 @@ importers:ajv:specifier: ^8.17.1version: 8.20.0-- better-sqlite3:-- specifier: ^11.9.1-- version: 11.10.0++ iovalkey:++ specifier: ^0.4.0++ version: 0.4.0openvibe-contracts:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0openvibe-sdk:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected])openvibe-shared:-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0-- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1++ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1++ pg:++ specifier: ^8.23.0++ version: 8.23.0tsx:specifier: ^4.19.3version: 4.23.11@@@ -127,9 +132,6 @@ importers:specifier: ^8.18.1version: 8.21.3devDependencies:-- '@types/better-sqlite3':-- specifier: ^7.6.12-- version: 7.6.13'@types/ws':specifier: ^8.18.0version: 8.18.1@@@ -157,6 +159,9 @@ importers:packages/persistence:dependencies:++ '@electric-sql/pglite':++ specifier: ^0.5.8++ version: 0.5.8'@openvibe/gameplay':specifier: workspace:*version: link:../gameplay@@@ -166,13 +171,9 @@ importers:'@openvibe/shared':specifier: workspace:*version: link:../shared-- better-sqlite3:-- specifier: ^11.9.1-- version: 11.10.0-- devDependencies:-- '@types/better-sqlite3':-- specifier: ^7.6.12-- version: 7.6.13++ openvibe-sdk:++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected])packages/physics:dependencies:@@@ -198,72 +199,113 @@ importers:packages/shared: {}packages:--'@asamuzakjp/[email protected]':-- resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}-- engines: {node: ^22.13.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==,++ }++ engines: { node: ^22.13.0 || >=24.0.0 }'@asamuzakjp/[email protected]':-- resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}-- engines: {node: ^22.13.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==,++ }++ engines: { node: ^22.13.0 || >=24.0.0 }'@babylonjs/[email protected]':-- resolution: {integrity: sha512-TczrCENrG+ww09LcIpdXG2OPU8TE4Bh8cMi5nZQdiIXexpjvDoI5/BkRhkEz/Qczdn/3sQPyLxVMNR1uho8vMA==}++ resolution:++ {++ integrity: sha512-TczrCENrG+ww09LcIpdXG2OPU8TE4Bh8cMi5nZQdiIXexpjvDoI5/BkRhkEz/Qczdn/3sQPyLxVMNR1uho8vMA==,++ }peerDependencies:'@babylonjs/core': ^9.0.0'@babylonjs/[email protected]':-- resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}++ resolution:++ {++ integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==,++ }'@babylonjs/[email protected]':-- resolution: {integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==}++ resolution:++ {++ integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==,++ }'@babylonjs/[email protected]':-- resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}++ resolution:++ {++ integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==,++ }peerDependencies:'@babylonjs/core': ^9.0.0babylonjs-gltf2interface: ^9.0.0'@babylonjs/[email protected]':-- resolution: {integrity: sha512-eHs6dn+kasWufTUC2EcrXaTTkWxbGDW0d7U1SyrsUc+ldYb4ktCb9taaoxooGhvLdmrCfd8GaqLvCSBf71Y0fw==}++ resolution:++ {++ integrity: sha512-eHs6dn+kasWufTUC2EcrXaTTkWxbGDW0d7U1SyrsUc+ldYb4ktCb9taaoxooGhvLdmrCfd8GaqLvCSBf71Y0fw==,++ }peerDependencies:'@babylonjs/core': ^9.0.0'@babylonjs/[email protected]':-- resolution: {integrity: sha512-ABrxLCVnB/xkq+Sgw7r+0qs9J8QJr4fit0YI6eysqTNUeP751Ob/1VAlxLllGN7nG5cIlCVP152/t3VanxXgzQ==}++ resolution:++ {++ integrity: sha512-ABrxLCVnB/xkq+Sgw7r+0qs9J8QJr4fit0YI6eysqTNUeP751Ob/1VAlxLllGN7nG5cIlCVP152/t3VanxXgzQ==,++ }peerDependencies:'@babylonjs/core': ^9.0.0'@bramus/[email protected]':-- resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}++ resolution:++ {++ integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==,++ }hasBin: true'@csstools/[email protected]':-- resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==,++ }++ engines: { node: '>=20.19.0' }'@csstools/[email protected]':-- resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==,++ }++ engines: { node: '>=20.19.0' }peerDependencies:'@csstools/css-parser-algorithms': ^4.0.0'@csstools/css-tokenizer': ^4.0.0'@csstools/[email protected]':-- resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==,++ }++ engines: { node: '>=20.19.0' }peerDependencies:'@csstools/css-parser-algorithms': ^4.0.0'@csstools/css-tokenizer': ^4.0.0'@csstools/[email protected]':-- resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==,++ }++ engines: { node: '>=20.19.0' }peerDependencies:'@csstools/css-tokenizer': ^4.0.0'@csstools/[email protected]':-- resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}++ resolution:++ {++ integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==,++ }peerDependencies:css-tree: ^3.2.1peerDependenciesMeta:@@@ -271,362 +313,557 @@ packages:optional: true'@csstools/[email protected]':-- resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==,++ }++ engines: { node: '>=20.19.0' }++++ '@electric-sql/[email protected]':++ resolution:++ {++ integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==,++ }'@esbuild/[email protected]':-- resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==,++ }++ engines: { node: '>=18' }cpu: [ppc64]os: [aix]'@esbuild/[email protected]':-- resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==,++ }++ engines: { node: '>=18' }cpu: [ppc64]os: [aix]'@esbuild/[email protected]':-- resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==,++ }++ engines: { node: '>=18' }cpu: [arm]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==,++ }++ engines: { node: '>=18' }cpu: [arm]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [android]'@esbuild/[email protected]':-- resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [darwin]'@esbuild/[email protected]':-- resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [darwin]'@esbuild/[email protected]':-- resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [darwin]'@esbuild/[email protected]':-- resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [darwin]'@esbuild/[email protected]':-- resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [freebsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [freebsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [freebsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [freebsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==,++ }++ engines: { node: '>=18' }cpu: [arm]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==,++ }++ engines: { node: '>=18' }cpu: [arm]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==,++ }++ engines: { node: '>=18' }cpu: [ia32]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==,++ }++ engines: { node: '>=18' }cpu: [ia32]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==,++ }++ engines: { node: '>=18' }cpu: [loong64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==,++ }++ engines: { node: '>=18' }cpu: [loong64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==,++ }++ engines: { node: '>=18' }cpu: [mips64el]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==,++ }++ engines: { node: '>=18' }cpu: [mips64el]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==,++ }++ engines: { node: '>=18' }cpu: [ppc64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==,++ }++ engines: { node: '>=18' }cpu: [ppc64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==,++ }++ engines: { node: '>=18' }cpu: [riscv64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==,++ }++ engines: { node: '>=18' }cpu: [riscv64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==,++ }++ engines: { node: '>=18' }cpu: [s390x]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==,++ }++ engines: { node: '>=18' }cpu: [s390x]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [linux]'@esbuild/[email protected]':-- resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [netbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [netbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [netbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [netbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [openbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [openbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [openbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [openbsd]'@esbuild/[email protected]':-- resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [openharmony]'@esbuild/[email protected]':-- resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [openharmony]'@esbuild/[email protected]':-- resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [sunos]'@esbuild/[email protected]':-- resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [sunos]'@esbuild/[email protected]':-- resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [win32]'@esbuild/[email protected]':-- resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==,++ }++ engines: { node: '>=18' }cpu: [arm64]os: [win32]'@esbuild/[email protected]':-- resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==,++ }++ engines: { node: '>=18' }cpu: [ia32]os: [win32]'@esbuild/[email protected]':-- resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==,++ }++ engines: { node: '>=18' }cpu: [ia32]os: [win32]'@esbuild/[email protected]':-- resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [win32]'@esbuild/[email protected]':-- resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==,++ }++ engines: { node: '>=18' }cpu: [x64]os: [win32]'@eslint-community/[email protected]':-- resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}-- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}++ resolution:++ {++ integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==,++ }++ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }peerDependencies:eslint: ^6.0.0 || ^7.0.0 || >=8.0.0'@eslint-community/[email protected]':-- resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}-- engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}++ resolution:++ {++ integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==,++ }++ engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@eslint/[email protected]':-- resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@exodus/[email protected]':-- resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}-- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==,++ }++ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }peerDependencies:'@noble/hashes': ^1.8.0 || ^2.0.0peerDependenciesMeta:@@@ -634,247 +871,403 @@ packages:optional: true'@humanfs/[email protected]':-- resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}-- engines: {node: '>=18.18.0'}++ resolution:++ {++ integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==,++ }++ engines: { node: '>=18.18.0' }'@humanfs/[email protected]':-- resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}-- engines: {node: '>=18.18.0'}++ resolution:++ {++ integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==,++ }++ engines: { node: '>=18.18.0' }'@humanfs/[email protected]':-- resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}-- engines: {node: '>=18.18.0'}++ resolution:++ {++ integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==,++ }++ engines: { node: '>=18.18.0' }'@humanwhocodes/[email protected]':-- resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}-- engines: {node: '>=12.22'}++ resolution:++ {++ integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==,++ }++ engines: { node: '>=12.22' }'@humanwhocodes/[email protected]':-- resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}-- engines: {node: '>=18.18'}++ resolution:++ {++ integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==,++ }++ engines: { node: '>=18.18' }++++ '@iovalkey/[email protected]':++ resolution:++ {++ integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==,++ }'@jridgewell/[email protected]':-- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}++ resolution:++ {++ integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==,++ }'@napi-rs/[email protected]':-- resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}-- engines: {node: ^22.20 || ^24.12 || >=25}++ resolution:++ {++ integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==,++ }++ engines: { node: ^22.20 || ^24.12 || >=25 }cpu: [x64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}++ resolution:++ {++ integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==,++ }cpu: [arm]os: [android]'@rollup/[email protected]':-- resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}++ resolution:++ {++ integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==,++ }cpu: [arm64]os: [android]'@rollup/[email protected]':-- resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}++ resolution:++ {++ integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==,++ }cpu: [arm64]os: [darwin]'@rollup/[email protected]':-- resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}++ resolution:++ {++ integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==,++ }cpu: [x64]os: [darwin]'@rollup/[email protected]':-- resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}++ resolution:++ {++ integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==,++ }cpu: [arm64]os: [freebsd]'@rollup/[email protected]':-- resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}++ resolution:++ {++ integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==,++ }cpu: [x64]os: [freebsd]'@rollup/[email protected]':-- resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}++ resolution:++ {++ integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==,++ }cpu: [arm]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}++ resolution:++ {++ integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==,++ }cpu: [arm]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}++ resolution:++ {++ integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==,++ }cpu: [arm64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}++ resolution:++ {++ integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==,++ }cpu: [arm64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}++ resolution:++ {++ integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==,++ }cpu: [loong64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}++ resolution:++ {++ integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==,++ }cpu: [loong64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}++ resolution:++ {++ integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==,++ }cpu: [ppc64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}++ resolution:++ {++ integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==,++ }cpu: [ppc64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}++ resolution:++ {++ integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==,++ }cpu: [riscv64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}++ resolution:++ {++ integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==,++ }cpu: [riscv64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}++ resolution:++ {++ integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==,++ }cpu: [s390x]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}++ resolution:++ {++ integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==,++ }cpu: [x64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}++ resolution:++ {++ integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==,++ }cpu: [x64]os: [linux]'@rollup/[email protected]':-- resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}++ resolution:++ {++ integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==,++ }cpu: [x64]os: [openbsd]'@rollup/[email protected]':-- resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}++ resolution:++ {++ integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==,++ }cpu: [arm64]os: [openharmony]'@rollup/[email protected]':-- resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}++ resolution:++ {++ integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==,++ }cpu: [arm64]os: [win32]'@rollup/[email protected]':-- resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}++ resolution:++ {++ integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==,++ }cpu: [ia32]os: [win32]'@rollup/[email protected]':-- resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}++ resolution:++ {++ integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==,++ }cpu: [x64]os: [win32]'@rollup/[email protected]':-- resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}++ resolution:++ {++ integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==,++ }cpu: [x64]os: [win32]-- '@types/[email protected]':-- resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}--'@types/[email protected]':-- resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}++ resolution:++ {++ integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}++ resolution:++ {++ integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}++ resolution:++ {++ integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}++ resolution:++ {++ integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}++ resolution:++ {++ integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}++ resolution:++ {++ integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==,++ }'@types/[email protected]':-- resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}++ resolution:++ {++ integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==,++ }'@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:'@typescript-eslint/parser': ^8.66.0eslint: ^8.57.0 || ^9.0.0 || ^10.0.0typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:eslint: ^8.57.0 || ^9.0.0 || ^10.0.0typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:eslint: ^8.57.0 || ^9.0.0 || ^10.0.0typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:eslint: ^8.57.0 || ^9.0.0 || ^10.0.0typescript: '>=4.8.4 <6.1.0''@typescript-eslint/[email protected]':-- resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }'@vitest/[email protected]':-- resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}++ resolution:++ {++ integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==,++ }'@vitest/[email protected]':-- resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}++ resolution:++ {++ integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==,++ }peerDependencies:msw: ^2.4.9vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0@@@ -885,32 +1278,56 @@ packages:optional: true'@vitest/[email protected]':-- resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}++ resolution:++ {++ integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==,++ }'@vitest/[email protected]':-- resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}++ resolution:++ {++ integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==,++ }'@vitest/[email protected]':-- resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}++ resolution:++ {++ integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==,++ }'@vitest/[email protected]':-- resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}++ resolution:++ {++ integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==,++ }'@vitest/[email protected]':-- resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}++ resolution:++ {++ integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==,++ }-- resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}++ resolution:++ {++ integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==,++ }peerDependencies:acorn: ^6.0.0 || ^7.0.0 || ^8.0.0-- resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}-- engines: {node: '>=0.4.0'}++ resolution:++ {++ integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==,++ }++ engines: { node: '>=0.4.0' }hasBin: true-- resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}++ resolution:++ {++ integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==,++ }peerDependencies:ajv: ^8.0.0peerDependenciesMeta:@@@ -918,108 +1335,169 @@ packages:optional: true-- resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}++ resolution:++ {++ integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==,++ }-- resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}++ resolution:++ {++ integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==,++ }-- resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}++ resolution:++ {++ integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==,++ }-- resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}-- engines: {node: '>=12'}++ resolution:++ {++ integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==,++ }++ engines: { node: '>=12' }-- resolution: {integrity: sha512-BxqokOP3obVy32CZ9ZV8VQqrVvodiBvk6e3T0Eg9PHM2i6rFXtpXeSEX3isNyPVotonLdBkpeTk2RvRqYIQMEg==}++ resolution:++ {++ integrity: sha512-BxqokOP3obVy32CZ9ZV8VQqrVvodiBvk6e3T0Eg9PHM2i6rFXtpXeSEX3isNyPVotonLdBkpeTk2RvRqYIQMEg==,++ }-- resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}++ resolution:++ {++ integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==,++ }-- resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}-- engines: {node: 18 || 20 || >=22}---- resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}---- resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}++ resolution:++ {++ integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==,++ }++ engines: { node: 18 || 20 || >=22 }-- resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}---- resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}---- resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}++ resolution:++ {++ integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==,++ }-- resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}++ resolution:++ {++ integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==,++ }-- resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}-- engines: {node: 20 || >=22}++ resolution:++ {++ integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==,++ }++ engines: { node: 20 || >=22 }-- resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}---- resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}++ resolution:++ {++ integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==,++ }-- resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}-- engines: {node: '>=6'}++ resolution:++ {++ integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==,++ }++ engines: { node: '>=6' }-- resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==,++ }++ engines: { node: '>=18' }-- resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}-- engines: {node: '>= 16'}---- resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}++ resolution:++ {++ integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==,++ }++ engines: { node: '>= 16' }++++ resolution:++ {++ integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}-- engines: {node: '>=7.0.0'}++ resolution:++ {++ integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==,++ }++ engines: { node: '>=7.0.0' }-- resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}++ resolution:++ {++ integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,++ }-- resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}++ resolution:++ {++ integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==,++ }-- resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}-- engines: {node: '>= 8'}++ resolution:++ {++ integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==,++ }++ engines: { node: '>= 8' }-- resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}-- engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}++ resolution:++ {++ integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==,++ }++ engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 }-- resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}-- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==,++ }++ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }-- resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}-- engines: {node: '>=6.0'}++ resolution:++ {++ integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==,++ }++ engines: { node: '>=6.0' }peerDependencies:supports-color: '*'peerDependenciesMeta:@@@ -1027,73 +1505,107 @@ packages:optional: true-- resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}---- resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==,++ }-- resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}-- engines: {node: '>=6'}---- resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}-- engines: {node: '>=4.0.0'}++ resolution:++ {++ integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==,++ }++ engines: { node: '>=6' }-- resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}---- resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==,++ }++++ resolution:++ {++ integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==,++ }++ engines: { node: '>=0.10' }-- resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}---- resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}++ resolution:++ {++ integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==,++ }-- resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}-- engines: {node: '>=20.19.0'}++ resolution:++ {++ integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==,++ }++ engines: { node: '>=20.19.0' }-- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}++ resolution:++ {++ integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==,++ }-- resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==,++ }++ engines: { node: '>=18' }hasBin: true-- resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==,++ }++ engines: { node: '>=18' }hasBin: true-- resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }-- resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}-- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}++ resolution:++ {++ integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==,++ }++ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }-- resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }-- resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}-- engines: {node: ^20.19.0 || ^22.13.0 || >=24}++ resolution:++ {++ integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==,++ }++ engines: { node: ^20.19.0 || ^22.13.0 || >=24 }-- resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }hasBin: truepeerDependencies:jiti: '*'@@@ -1102,51 +1614,83 @@ packages:optional: true-- resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }-- resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}-- engines: {node: '>=0.10'}++ resolution:++ {++ integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==,++ }++ engines: { node: '>=0.10' }-- resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}-- engines: {node: '>=4.0'}++ resolution:++ {++ integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==,++ }++ engines: { node: '>=4.0' }-- resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}-- engines: {node: '>=4.0'}++ resolution:++ {++ integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==,++ }++ engines: { node: '>=4.0' }-- resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}++ resolution:++ {++ integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==,++ }-- resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}-- engines: {node: '>=0.10.0'}---- resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}-- engines: {node: '>=6'}++ resolution:++ {++ integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}-- engines: {node: '>=12.0.0'}++ resolution:++ {++ integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==,++ }++ engines: { node: '>=12.0.0' }-- resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}++ resolution:++ {++ integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,++ }-- resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}++ resolution:++ {++ integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==,++ }-- resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}++ resolution:++ {++ integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==,++ }-- resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}++ resolution:++ {++ integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==,++ }-- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}-- engines: {node: '>=12.0.0'}++ resolution:++ {++ integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==,++ }++ engines: { node: '>=12.0.0' }peerDependencies:picomatch: ^3 || ^4peerDependenciesMeta:@@@ -1154,104 +1698,156 @@ packages:optional: true-- resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}-- engines: {node: '>=16.0.0'}---- resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}++ resolution:++ {++ integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==,++ }++ engines: { node: '>=16.0.0' }-- resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}-- engines: {node: '>=16'}++ resolution:++ {++ integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==,++ }++ engines: { node: '>=16' }-- resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}---- resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}++ resolution:++ {++ integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==,++ }-- resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}-- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}++ resolution:++ {++ integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==,++ }++ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }os: [darwin]-- resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}-- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}++ resolution:++ {++ integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==,++ }++ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }os: [darwin]-- resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}---- resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}-- engines: {node: '>=10.13.0'}++ resolution:++ {++ integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==,++ }++ engines: { node: '>=10.13.0' }-- resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==,++ }++ engines: { node: '>=18' }-- resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}-- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}---- resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}++ resolution:++ {++ integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==,++ }++ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }-- resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}-- engines: {node: '>= 4'}++ resolution:++ {++ integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==,++ }++ engines: { node: '>= 4' }-- resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}-- engines: {node: '>= 4'}++ resolution:++ {++ integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==,++ }++ engines: { node: '>= 4' }-- resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}-- engines: {node: '>=6'}++ resolution:++ {++ integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==,++ }++ engines: { node: '>=6' }-- resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}-- engines: {node: '>=0.8.19'}---- resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}---- resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}++ resolution:++ {++ integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==,++ }++ engines: { node: '>=0.8.19' }++++ resolution:++ {++ integrity: sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==,++ }++ engines: { node: '>=18.12.0' }-- resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}-- engines: {node: '>=0.10.0'}++ resolution:++ {++ integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}-- engines: {node: '>=0.10.0'}++ resolution:++ {++ integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}++ resolution:++ {++ integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==,++ }-- resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}++ resolution:++ {++ integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==,++ }-- resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}++ resolution:++ {++ integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==,++ }-- resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}++ resolution:++ {++ integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==,++ }hasBin: true-- resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}-- engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}++ resolution:++ {++ integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==,++ }++ engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 }peerDependencies:canvas: ^3.2.3peerDependenciesMeta:@@@ -1259,381 +1855,702 @@ packages:optional: true-- resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}++ resolution:++ {++ integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==,++ }-- resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}++ resolution:++ {++ integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==,++ }-- resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}++ resolution:++ {++ integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==,++ }-- resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}++ resolution:++ {++ integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==,++ }-- resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}-- engines: {node: '>=12', npm: '>=6'}++ resolution:++ {++ integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==,++ }++ engines: { node: '>=12', npm: '>=6' }-- resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}++ resolution:++ {++ integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==,++ }-- resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}++ resolution:++ {++ integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==,++ }-- resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}++ resolution:++ {++ integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==,++ }-- resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}-- engines: {node: '>= 0.8.0'}++ resolution:++ {++ integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==,++ }++ engines: { node: '>= 0.8.0' }-- resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==,++ }++ engines: { node: '>=10' }++++ resolution:++ {++ integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==,++ }-- resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}++ resolution:++ {++ integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==,++ }++++ resolution:++ {++ integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==,++ }-- resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}++ resolution:++ {++ integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==,++ }-- resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}++ resolution:++ {++ integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==,++ }-- resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}++ resolution:++ {++ integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==,++ }-- resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}++ resolution:++ {++ integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==,++ }-- resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}++ resolution:++ {++ integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==,++ }-- resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}++ resolution:++ {++ integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,++ }-- resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}++ resolution:++ {++ integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==,++ }-- resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}++ resolution:++ {++ integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==,++ }-- resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}-- engines: {node: 20 || >=22}++ resolution:++ {++ integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==,++ }++ engines: { node: 20 || >=22 }-- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}++ resolution:++ {++ integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==,++ }-- resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}---- resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==,++ }-- resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}-- engines: {node: 18 || 20 || >=22}++ resolution:++ {++ integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==,++ }++ engines: { node: 18 || 20 || >=22 }-- resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}---- resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}---- resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}++ resolution:++ {++ integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==,++ }-- resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}++ resolution:++ {++ integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==,++ }-- resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}-- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}++ resolution:++ {++ integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==,++ }++ engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 }hasBin: true-- resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}---- resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}---- resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==}-- engines: {node: '>=10'}---- resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}++ resolution:++ {++ integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==,++ }openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0}++ resolution:++ {++ tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0,++ }version: 0.71.0-- engines: {node: '>=20'}++ engines: { node: '>=20' }hasBin: true-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0}-- version: 0.12.0-- engines: {node: '>=20'}++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0:++ resolution:++ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0 }++ version: 0.22.0++ engines: { node: '>=20' }++ peerDependencies:++ '@electric-sql/pglite': ^0.5.8++ better-sqlite3: '>=11'++ iovalkey: ^0.4.0++ pg: ^8.23.0++ peerDependenciesMeta:++ '@electric-sql/pglite':++ optional: true++ better-sqlite3:++ optional: true++ iovalkey:++ optional: true++ pg:++ optional: true-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0}-- version: 1.23.0++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:++ resolution:++ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1 }++ version: 1.30.1-- resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}-- engines: {node: '>= 0.8.0'}++ resolution:++ {++ integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==,++ }++ engines: { node: '>= 0.8.0' }-- resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}-- engines: {node: '>=6'}++ resolution:++ {++ integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==,++ }++ engines: { node: '>=6' }-- resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}++ resolution:++ {++ integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==,++ }-- resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}++ resolution:++ {++ integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==,++ }-- resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}-- engines: {node: '>= 14.16'}++ resolution:++ {++ integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==,++ }++ engines: { node: '>= 14.16' }++++ resolution:++ {++ integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==,++ }++++ resolution:++ {++ integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==,++ }++++ resolution:++ {++ integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==,++ }++ engines: { node: '>=4.0.0' }++++ resolution:++ {++ integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==,++ }++ peerDependencies:++ pg: '>=8.0'++++ resolution:++ {++ integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==,++ }++++ resolution:++ {++ integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==,++ }++ engines: { node: '>=4' }++++ resolution:++ {++ integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==,++ }++ engines: { node: '>= 16.0.0' }++ peerDependencies:++ pg-native: '>=3.0.1'++ peerDependenciesMeta:++ pg-native:++ optional: true++++ resolution:++ {++ integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==,++ }-- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}++ resolution:++ {++ integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==,++ }-- resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}-- engines: {node: '>=12'}++ resolution:++ {++ integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==,++ }++ engines: { node: '>=12' }-- resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}-- engines: {node: '>=20'}++ resolution:++ {++ integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==,++ }++ engines: { node: '>=20' }hasBin: true-- resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}-- engines: {node: '>=20'}++ resolution:++ {++ integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==,++ }++ engines: { node: '>=20' }hasBin: true-- resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}-- engines: {node: ^10 || ^12 || >=14}---- resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}-- engines: {node: '>=10'}-- deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.-- hasBin: true++ resolution:++ {++ integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==,++ }++ engines: { node: ^10 || ^12 || >=14 }++++ resolution:++ {++ integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==,++ }++ engines: { node: '>=4' }++++ resolution:++ {++ integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==,++ }++ engines: { node: '>=0.10.0' }++++ resolution:++ {++ integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==,++ }++ engines: { node: '>=0.10.0' }++++ resolution:++ {++ integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}-- engines: {node: '>= 0.8.0'}++ resolution:++ {++ integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==,++ }++ engines: { node: '>= 0.8.0' }-- resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}-- engines: {node: '>=14'}++ resolution:++ {++ integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==,++ }++ engines: { node: '>=14' }hasBin: true-- resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}---- resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}-- engines: {node: '>=6'}---- resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}-- hasBin: true---- resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}-- engines: {node: '>= 6'}++ resolution:++ {++ integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==,++ }++ engines: { node: '>=6' }++++ resolution:++ {++ integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==,++ }++ engines: { node: '>=4' }++++ resolution:++ {++ integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==,++ }++ engines: { node: '>=4' }-- resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}-- engines: {node: '>=0.10.0'}++ resolution:++ {++ integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}-- engines: {node: '>=4'}++ resolution:++ {++ integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==,++ }++ engines: { node: '>=4' }-- resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}-- engines: {node: '>=18.0.0', npm: '>=8.0.0'}++ resolution:++ {++ integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==,++ }++ engines: { node: '>=18.0.0', npm: '>=8.0.0' }hasBin: true-- resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}++ resolution:++ {++ integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==,++ }-- resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}-- engines: {node: '>=v12.22.7'}++ resolution:++ {++ integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==,++ }++ engines: { node: '>=v12.22.7' }-- resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==,++ }++ engines: { node: '>=10' }hasBin: true-- resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}---- resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}---- resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}++ resolution:++ {++ integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==,++ }-- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}-- engines: {node: '>=0.10.0'}++ resolution:++ {++ integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==,++ }++ engines: { node: '>=0.10.0' }++++ resolution:++ {++ integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==,++ }++ engines: { node: '>= 10.x' }-- resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}---- resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}++ resolution:++ {++ integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==,++ }-- resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}++ resolution:++ {++ integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==,++ }-- resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}-- engines: {node: '>=0.10.0'}++ resolution:++ {++ integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==,++ }-- resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}++ resolution:++ {++ integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==,++ }-- resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==,++ }++ engines: { node: '>=8' }-- resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}---- resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}---- resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}-- engines: {node: '>=6'}++ resolution:++ {++ integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==,++ }-- resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}++ resolution:++ {++ integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==,++ }-- resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}++ resolution:++ {++ integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==,++ }-- resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}-- engines: {node: '>=12.0.0'}++ resolution:++ {++ integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==,++ }++ engines: { node: '>=12.0.0' }-- resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}-- engines: {node: ^18.0.0 || >=20.0.0}++ resolution:++ {++ integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==,++ }++ engines: { node: ^18.0.0 || >=20.0.0 }-- resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}-- engines: {node: '>=14.0.0'}++ resolution:++ {++ integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==,++ }++ engines: { node: '>=14.0.0' }-- resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}-- engines: {node: '>=14.0.0'}++ resolution:++ {++ integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==,++ }++ engines: { node: '>=14.0.0' }-- resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}++ resolution:++ {++ integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==,++ }-- resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}++ resolution:++ {++ integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==,++ }hasBin: true-- resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}-- engines: {node: '>=16'}++ resolution:++ {++ integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==,++ }++ engines: { node: '>=16' }-- resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}-- engines: {node: '>=20'}++ resolution:++ {++ integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==,++ }++ engines: { node: '>=20' }-- resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}-- engines: {node: '>=18.12'}++ resolution:++ {++ integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==,++ }++ engines: { node: '>=18.12' }peerDependencies:typescript: '>=4.8.4'-- resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}-- engines: {node: '>=18.0.0'}++ resolution:++ {++ integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==,++ }++ engines: { node: '>=18.0.0' }hasBin: true-- resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}---- resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}-- engines: {node: '>= 0.8.0'}++ resolution:++ {++ integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==,++ }++ engines: { node: '>= 0.8.0' }-- resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}-- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}++ resolution:++ {++ integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==,++ }++ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }peerDependencies:eslint: ^8.57.0 || ^9.0.0 || ^10.0.0typescript: '>=4.8.4 <6.1.0'-- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}-- engines: {node: '>=14.17'}++ resolution:++ {++ integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==,++ }++ engines: { node: '>=14.17' }hasBin: true-- resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}++ resolution:++ {++ integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==,++ }-- resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}-- engines: {node: '>=22.19.0'}++ resolution:++ {++ integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==,++ }++ engines: { node: '>=22.19.0' }-- resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}---- resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}++ resolution:++ {++ integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==,++ }-- resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}-- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}++ resolution:++ {++ integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==,++ }++ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }hasBin: true-- resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}-- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}++ resolution:++ {++ integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==,++ }++ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }hasBin: truepeerDependencies:'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0@@@ -1672,8 +2589,11 @@ packages:optional: true-- resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}-- engines: {node: ^20.19.0 || >=22.12.0}++ resolution:++ {++ integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==,++ }++ engines: { node: ^20.19.0 || >=22.12.0 }hasBin: truepeerDependencies:'@types/node': ^20.19.0 || >=22.12.0@@@ -1712,8 +2632,11 @@ packages:optional: true-- resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}-- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}++ resolution:++ {++ integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==,++ }++ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }hasBin: truepeerDependencies:'@edge-runtime/vm': '*'@@@ -1740,45 +2663,69 @@ packages:optional: true-- resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==,++ }++ engines: { node: '>=18' }-- resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}-- engines: {node: '>=20'}++ resolution:++ {++ integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==,++ }++ engines: { node: '>=20' }-- resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}-- engines: {node: '>=20'}++ resolution:++ {++ integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==,++ }++ engines: { node: '>=20' }-- resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}-- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==,++ }++ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }-- resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}-- engines: {node: ^22.14.0 || >=24.0.0}++ resolution:++ {++ integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==,++ }++ engines: { node: ^22.14.0 || >=24.0.0 }-- resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}-- engines: {node: '>= 8'}++ resolution:++ {++ integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==,++ }++ engines: { node: '>= 8' }hasBin: true-- resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}-- engines: {node: '>=8'}++ resolution:++ {++ integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==,++ }++ engines: { node: '>=8' }hasBin: true-- resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}-- engines: {node: '>=0.10.0'}---- resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}++ resolution:++ {++ integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==,++ }++ engines: { node: '>=0.10.0' }-- resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}-- engines: {node: '>=10.0.0'}++ resolution:++ {++ integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==,++ }++ engines: { node: '>=10.0.0' }peerDependencies:bufferutil: ^4.0.1utf-8-validate: '>=5.0.2'@@@ -1789,21 +2736,39 @@ packages:optional: true-- resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}-- engines: {node: '>=18'}++ resolution:++ {++ integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==,++ }++ engines: { node: '>=18' }-- resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}++ resolution:++ {++ integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==,++ }++++ resolution:++ {++ integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==,++ }++ engines: { node: '>=0.4' }-- resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}-- engines: {node: '>=10'}++ resolution:++ {++ integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==,++ }++ engines: { node: '>=10' }-- resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}++ resolution:++ {++ integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==,++ }snapshots:--'@asamuzakjp/[email protected]':dependencies:'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])@@@ -1870,6 +2835,8 @@ snapshots:'@csstools/[email protected]': {}++ '@electric-sql/[email protected]': {}++'@esbuild/[email protected]':optional: true@@@ -2090,6 +3057,8 @@ snapshots:'@humanwhocodes/[email protected]': {}++ '@iovalkey/[email protected]': {}++'@jridgewell/[email protected]': {}'@napi-rs/[email protected]':@@@ -2170,10 +3139,6 @@ snapshots:'@rollup/[email protected]':optional: true-- '@types/[email protected]':-- dependencies:-- '@types/node': 22.20.1--'@types/[email protected]':dependencies:'@types/deep-eql': 4.0.2@@@ -2366,27 +3331,10 @@ snapshots:-- [email protected]: {}---- dependencies:-- bindings: 1.5.0-- prebuild-install: 7.1.3--dependencies:require-from-string: 2.0.2-- dependencies:-- file-uri-to-path: 1.0.0---- dependencies:-- buffer: 5.7.1-- inherits: 2.0.4-- readable-stream: 3.6.2--dependencies:balanced-match: 1.0.2@@@ -2398,11 +3346,6 @@ snapshots:-- dependencies:-- base64-js: 1.5.1-- ieee754: 1.2.1--@@@ -2422,7 +3365,7 @@ snapshots:-- [email protected]: {}++ [email protected]: {}dependencies:@@@ -2456,26 +3399,16 @@ snapshots:-- dependencies:-- mimic-response: 3.1.0---- [email protected]: {}---- [email protected]: {}++ [email protected]: {}dependencies:safe-buffer: 5.2.1-- dependencies:-- once: 1.4.0--@@@ -2612,8 +3545,6 @@ snapshots:-- [email protected]: {}--@@@ -2632,8 +3563,6 @@ snapshots:dependencies:flat-cache: 4.0.1-- [email protected]: {}--dependencies:locate-path: 6.0.0@@@ -2646,16 +3575,12 @@ snapshots:-- [email protected]: {}--optional: trueoptional: true-- [email protected]: {}--dependencies:is-glob: 4.0.3@@@ -2670,8 +3595,6 @@ snapshots:transitivePeerDependencies:- '@noble/hashes'-- [email protected]: {}--@@@ -2683,9 +3606,19 @@ snapshots:-- [email protected]: {}---- [email protected]: {}++ dependencies:++ '@iovalkey/commands': 0.1.0++ cluster-key-slot: 1.1.2++ debug: 4.4.3++ denque: 2.1.0++ lodash.defaults: 4.2.0++ lodash.isarguments: 3.1.0++ redis-errors: 1.2.0++ redis-parser: 3.0.0++ standard-as-callback: 2.1.0++ transitivePeerDependencies:++ - supports-color@@@ -2774,8 +3707,12 @@ snapshots:dependencies:p-locate: 5.0.0++ [email protected]: {}++++ [email protected]: {}++@@@ -2800,8 +3737,6 @@ snapshots:-- [email protected]: {}--dependencies:brace-expansion: 5.0.9@@@ -2810,34 +3745,24 @@ snapshots:dependencies:brace-expansion: 1.1.18-- [email protected]: {}---- [email protected]: {}---- [email protected]: {}---- dependencies:-- semver: 7.8.5---- dependencies:-- wrappy: 1.0.2--openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:dependencies:ajv: 8.20.0ajv-formats: 3.0.1([email protected])-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0: {}++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected]):++ optionalDependencies:++ '@electric-sql/pglite': 0.5.8++ iovalkey: 0.4.0++ pg: 8.23.0-- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:++ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:dependencies:jsonwebtoken: 9.0.3@@@ -2874,6 +3799,41 @@ snapshots:++ optional: true++++ [email protected]: {}++++ [email protected]: {}++++ dependencies:++ pg: 8.23.0++++ [email protected]: {}++++ dependencies:++ pg-int8: 1.0.1++ postgres-array: 2.0.0++ postgres-bytea: 1.0.1++ postgres-date: 1.0.7++ postgres-interval: 1.2.0++++ dependencies:++ pg-connection-string: 2.14.0++ pg-pool: 3.14.0([email protected])++ pg-protocol: 1.16.0++ pg-types: 2.2.0++ pgpass: 1.0.5++ optionalDependencies:++ pg-cloudflare: 1.4.0++++ dependencies:++ split2: 4.2.0++@@@ -2892,44 +3852,27 @@ snapshots:picocolors: 1.1.1source-map-js: 1.2.1-- dependencies:-- detect-libc: 2.1.2-- expand-template: 2.0.3-- github-from-package: 0.0.0-- minimist: 1.2.8-- mkdirp-classic: 0.5.3-- napi-build-utils: 2.0.0-- node-abi: 3.94.0-- pump: 3.0.4-- rc: 1.2.8-- simple-get: 4.0.1-- tar-fs: 2.1.5-- tunnel-agent: 0.6.0++ [email protected]: {}-- [email protected]: {}++ [email protected]: {}-- [email protected]: {}++ [email protected]: {}dependencies:-- end-of-stream: 1.4.5-- once: 1.4.0++ xtend: 4.0.2++++ [email protected]: {}++++ [email protected]: {}-- dependencies:-- deep-extend: 0.6.0-- ini: 1.3.8-- minimist: 1.2.8-- strip-json-comments: 2.0.1++ [email protected]: {}dependencies:-- inherits: 2.0.4-- string_decoder: 1.3.0-- util-deprecate: 1.0.2++ redis-errors: 1.2.0@@@ -2983,25 +3926,15 @@ snapshots:-- [email protected]: {}---- dependencies:-- decompress-response: 6.0.0-- once: 1.4.0-- simple-concat: 1.0.1--++ [email protected]: {}++-- [email protected]: {}++ [email protected]: {}-- dependencies:-- safe-buffer: 5.2.1---- [email protected]: {}++ [email protected]: {}@@@ -3015,21 +3948,6 @@ snapshots:-- dependencies:-- chownr: 1.1.4-- mkdirp-classic: 0.5.3-- pump: 3.0.4-- tar-stream: 2.2.0---- dependencies:-- bl: 4.1.0-- end-of-stream: 1.4.5-- fs-constants: 1.0.0-- inherits: 2.0.4-- readable-stream: 3.6.2--@@@ -3069,10 +3987,6 @@ snapshots:optionalDependencies:fsevents: 2.3.3-- dependencies:-- safe-buffer: 5.2.1--dependencies:prelude-ls: 1.2.1@@@ -3098,8 +4012,6 @@ snapshots:dependencies:punycode: 2.3.1-- [email protected]: {}--dependencies:cac: 6.7.14@@@ -3224,14 +4136,14 @@ snapshots:-- [email protected]: {}--++ [email protected]: {}++@@@ -3,5 +3,4 @@ packages:- apps/*onlyBuiltDependencies:-- - better-sqlite3- esbuild@@@ -9,5 +9,8 @@ export default defineConfig({include: ['packages/**/*.test.ts', 'apps/**/*.test.ts', 'apps/**/*.audit.ts'],exclude: ['**/node_modules/**', '**/dist/**'],environment: 'node',++ // Every persistence test runs against a real in-memory PGlite (WASM), which takes seconds to start.++ testTimeout: 30_000,++ hookTimeout: 30_000,},})
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+# Names only; never commit real values. Copy to .env (gitignored) and fill in.
+
+# ── Persistence (ADR-0007 decision 5: PostgreSQL 18 + Valkey through openvibe-sdk) ──
+# Pooled PostgreSQL URL (through PgBouncer, transaction mode). Required in production.
+DATABASE_URL=
+# Owner-role direct connection migrations run on (a session, not PgBouncer). Required with DATABASE_URL.
+DATABASE_DIRECT_URL=
+# Development only: embedded PGlite directory when DATABASE_URL is unset (never production).
+DATABASE_DIR=data
+# Optional: another SQL migrations directory (default: @openvibe/persistence's own, from any working directory).
+GAMES_MIGRATIONS_DIR=
+# The place this instance serves; world rows and meta are keyed by it.
+GAMES_PLACE_ID=scraplandia
+
+# ── Valkey (optional): shared per-actor rate-limit counters across instances ──
+VALKEY_URL=
+VALKEY_PREFIX=ov:games:
+
+# ── Server ──
+PORT=8000
+HOST=0.0.0.0
+MAP_PATH=data/map.json
@@ -4,10 +4,7 @@ dist-types/
@@ -4,10 +4,7 @@ dist-types/
*.tsbuildinfo
*.tsbuildinfo
.env
.env
*.log
*.log
-# Runtime SQLite state, at the repo root or in any app (never a fixture).
-data/*.db
-data/*.db-*
-apps/*/data/*.db
-apps/*/data/*.db-*
+# Embedded development database (PGlite under the data dir), never a fixture.
+**/data/pglite/
.DS_Store
.DS_Store
scratch/
scratch/
@@ -7,5 +7,5 @@ useDefault = true
@@ -7,5 +7,5 @@ useDefault = true
[[allowlists]]
[[allowlists]]
description = "Made-up player/session tokens in unit tests"
description = "Made-up player/session tokens in unit tests"
condition = "AND"
condition = "AND"
-paths = ['''^packages/(persistence/src/sqlite/sqliteStore|protocol/src/codec)\.test\.ts$''']
+paths = ['''^packages/(persistence/src/pg/pgStore|protocol/src/codec)\.test\.ts$''']
regexes = ['''^(tok_12345678|abcdefgh12345678)$''']
regexes = ['''^(tok_12345678|abcdefgh12345678)$''']
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+# Do not auto-install missing peer dependencies: openvibe-sdk marks its storage/queue backends as
+# optional peers, and auto-installing all of them pulled a native database driver Games no longer uses
+# into the lockfile. The backends Games needs (pg, @electric-sql/pglite, iovalkey) are declared
+# dependencies, so the SDK still resolves them.
+auto-install-peers=false
@@ -28,14 +28,14 @@
@@ -28,14 +28,14 @@
\ | /
\ | /
gameplay
gameplay
|
|
- persistence (DTOs; sqlite impl behind repository interfaces)
+ persistence (DTOs; PostgreSQL impl behind repository interfaces; migrations)
|
|
apps/server apps/client
apps/server apps/client
(authoritative) (Babylon render + prediction + HUD)
(authoritative) (Babylon render + prediction + HUD)
```
```
Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only
Only `apps/*` and `packages/physics`'s adapter know Babylon exists. Only
-`apps/server` and `packages/persistence` know SQLite exists. `packages/*`
+`apps/server` and `packages/persistence` know PostgreSQL (openvibe-sdk/db) exists. `packages/*`
never import browser-only APIs (the physics adapter's NullEngine path is
never import browser-only APIs (the physics adapter's NullEngine path is
Node-safe; the client hands it a rendered scene instead).
Node-safe; the client hands it a rendered scene instead).
@@ -85,19 +85,27 @@ Node-safe; the client hands it a rendered scene instead).
@@ -85,19 +85,27 @@ Node-safe; the client hands it a rendered scene instead).
4. Sync awake prop transforms into entity records; settle/wake transitions
4. Sync awake prop transforms into entity records; settle/wake transitions
5. Crafting queues (tick-based completion, output-space aware)
5. Crafting queues (tick-based completion, output-space aware)
6. Replication (every 2nd tick): interest diff + snapshot per session
6. Replication (every 2nd tick): interest diff + snapshot per session
-7. Periodic batched persistence flush (dirty entities + dirty players)
+7. Periodic write-behind checkpoint (a dirty snapshot handed to the flusher; the tick never awaits I/O)
## Persistence
## Persistence
-SQLite (WAL) behind `PersistenceStore` repositories: `world_entities`
-(props/resources with motion + kind-specific state JSON), `players` (token,
-transform, inventory JSON), `meta` (schema version, world-seeded flag).
-Dirty tracking batches writes; flush on interval, on settle, and on shutdown.
-Each flush is one transaction, together with the outbox events describing
-it. First boot seeds from the world definition; afterwards the DB is the
-world's source of truth. Platform tables: `identity_adoptions`, `mods`,
-`mod_grants`, `mod_placements`, `mod_audit`, `media_mirrors`, and the SDK's
-`event_outbox`. The simulation never reads them.
+PostgreSQL 18 through openvibe-sdk/db, behind `PersistenceStore` repositories (the SDK's async handle
+and `sql` fragments; migrations in `packages/persistence/migrations/0001_initial.sql` applied at boot
+by the SDK migrator as the owner role). The schema: `places`, `world_meta`, `world_entities`
+(props/resources/NPCs with motion + kind-specific state JSON), `world_constraints`, `characters`
+(inventory, skills, friends, reputation, unlocks, stats, armor, active job), `mods`, `mod_grants`,
+`mod_placements`, `mod_audit`, `media_mirrors`, `identity_adoptions`, `account_data_events`, and the
+SDK's `event_outbox` and `token_revocations`. JSON columns are jsonb and round-trip as objects; a
+local guest account is keyed by `guest:` + sha256(their token), never the raw token.
+
+**The tick never awaits I/O.** It takes a copy-on-write snapshot of the dirty rows
+(`world.takeDirty()`, `npcs.takeDirty()`, dirty sessions, env/markets) and the write-behind flusher
+(`apps/server/src/game/flusher.ts`) persists it in one transaction, one flush in flight per instance.
+A checkpoint while one is running is coalesced (the dirty flags stay set), a failed transaction
+restores the snapshot's dirtiness and is retried, and shutdown drains the queue before the database
+closes. Batch writes are multi-row `INSERT … ON CONFLICT` in chunks of at most 500 rows. First boot
+seeds from the world definition; afterwards the database is the world's source of truth. The
+simulation never reads the platform tables.
## Platform boundary
## Platform boundary
@@ -9,12 +9,13 @@ extraction risk — running against an authoritative dedicated server.
@@ -9,12 +9,13 @@ extraction risk — running against an authoritative dedicated server.
**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·
**Stack:** TypeScript everywhere · Babylon.js (WebGPU with WebGL fallback) ·
Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·
Havok physics (Babylon Physics V2, headless on the server via NullEngine) ·
-WebSocket protocol · SQLite persistence · pnpm monorepo.
+WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm monorepo.
## Owns
## Owns
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
- the game: the authoritative simulation, the world, characters, inventories, skills, blueprints and
- props, in `world.db` (SQLite, forward-only migrations; `SCHEMA_VERSION` 11)
+ props, in PostgreSQL (ADR-0007 decision 5: schema from scratch, migrations under
+ `packages/persistence/migrations`, a write-behind flusher — the tick never awaits I/O)
- the map editor and its assets, the mod registry (`games-content@1` packs, grants, audit) and the
- the map editor and its assets, the mod registry (`games-content@1` packs, grants, audit) and the
`games.*` events
`games.*` events
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
- the game WebSocket protocol and the portal, `/play` and `/editor` pages
@@ -30,8 +31,9 @@ WebSocket protocol · SQLite persistence · pnpm monorepo.
@@ -30,8 +31,9 @@ WebSocket protocol · SQLite persistence · pnpm monorepo.
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
-- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.12.0 and `openvibe-shared` v1.23.0 (pinned by release
- tarball in `apps/server/package.json`), Babylon.js, Havok, better-sqlite3
+- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
+ tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in
+ development), Valkey (`iovalkey`, optional)
## Repository layout
## Repository layout
@@ -45,7 +47,7 @@ packages/
@@ -45,7 +47,7 @@ packages/
content/ Data-driven item/recipe/world definitions + validation registry
content/ Data-driven item/recipe/world definitions + validation registry
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
physics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)
physics/ PhysicsWorld abstraction + Havok adapter (@openvibe/physics/havok)
- persistence/ DTOs, repository interfaces, SQLite implementation
+ persistence/ DTOs, repository interfaces, PostgreSQL implementation, migrations
docs/adr/ Architecture decision records
docs/adr/ Architecture decision records
```
```
@@ -104,27 +106,34 @@ freeze. Props are protected: only you and players you trust can move them.
@@ -104,27 +106,34 @@ freeze. Props are protected: only you and players you trust can move them.
```bash
```bash
pnpm build
pnpm build
-STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openvibe/server start
+DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/client/dist PORT=8000 pnpm --filter @openvibe/server start
```
```
+`DATABASE_URL` (pooled, through PgBouncer) serves; migrations run at boot on `DATABASE_DIRECT_URL`
+(the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses
+an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process.
+`GAMES_PLACE_ID` (default `scraplandia`) is the place every world row belongs to; `VALKEY_URL`
+(optional) makes the per-actor limits shared across instances. See [.env.example](.env.example).
+
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is
WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is
the game and `/play` redirects there. Any other path that is neither a file of the build nor a route
the game and `/play` redirects there. Any other path that is neither a file of the build nor a route
answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text
answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text
-for a missing asset), never the portal with a 200. `/api/ready` is 200 only while `world.db` answers a query and the simulation
+for a missing asset), never the portal with a 200. `/api/ready` is 200 only while the database answers a real round trip
+(`db.ready()`) and the simulation
has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
-`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
-`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
+`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `STATIC_DIR`,
+`MAX_PLAYERS`, `LOG_LEVEL`, `GAMES_PLACE_ID`, the database variables above (platform variables below).
Deployed at https://openvibe.games (nginx TLS termination → server on 127.0.0.1:8000,
Deployed at https://openvibe.games (nginx TLS termination → server on 127.0.0.1:8000,
systemd unit `openvibe-games.service` from [deploy/openvibe-games.service](deploy/openvibe-games.service),
systemd unit `openvibe-games.service` from [deploy/openvibe-games.service](deploy/openvibe-games.service),
-env file `/etc/openvibe/games.env`, database `/opt/openvibe.games/data/world.db`;
+env file `/etc/openvibe/games.env` (which holds `DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL`);
play.openvibe.games Host-routes to the same process).
play.openvibe.games Host-routes to the same process).
Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs
Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs
`ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull as
`ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull as
the checkout owner, `pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`,
the checkout owner, `pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`,
-better-sqlite3 loaded under the host's Node, the restart, `/api/ready`, and on failure the checkout
+the restart (the SDK migrator applies any new migration as the owner before serving), `/api/ready`, and on failure the checkout
restored, reinstalled and rebuilt and the server restarted again. Players online are reported and
restored, reinstalled and rebuilt and the server restarted again. Players online are reported and
reconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`,
reconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`,
`DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass
`DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass
@@ -134,8 +143,8 @@ wrapper runs `deploy/scripts/deploy-legacy.sh`: the procedure as it was run by h
@@ -134,8 +143,8 @@ wrapper runs `deploy/scripts/deploy-legacy.sh`: the procedure as it was run by h
`sudo systemctl restart openvibe-games`).
`sudo systemctl restart openvibe-games`).
Rollback: `sudo deploy/scripts/deploy.sh --rollback` (`ovhost rollback games`), which rebuilds the previous
Rollback: `sudo deploy/scripts/deploy.sh --rollback` (`ovhost rollback games`), which rebuilds the previous
-commit. One blocker: a build refuses a `world.db` whose `meta.schema_version` is newer than its own, so going
-back past a schema bump fails at boot.
+commit. Migrations are additive and applied at boot, so an older build starts on the newer schema; the SDK
+migrator refuses a migration file that was edited after it ran (write a new one instead).
## Platform integration (OpenVibe network)
## Platform integration (OpenVibe network)
@@ -153,7 +162,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
@@ -153,7 +162,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
made for a player (`/api/auth/me`, code and FedCM exchanges) keep using the
made for a player (`/api/auth/me`, code and FedCM exchanges) keep using the
player's own token.
player's own token.
- **Events** (OpenVibe.Events, through a transactional outbox in
- **Events** (OpenVibe.Events, through a transactional outbox in
- `world.db`, table `event_outbox`): `games.player.joined|left`,
+ PostgreSQL, table `event_outbox`): `games.player.joined|left`,
`games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved`
`games.skill.leveled`, `games.blueprint.unlocked`, `games.world.saved`
(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and
(at most one checkpoint per `WORLD_SAVED_EVENT_MINUTES`, plus shutdown) and
`games.mod.installed|enabled|disabled|grants_changed|revoked`. There is no
`games.mod.installed|enabled|disabled|grants_changed|revoked`. There is no
@@ -3,17 +3,22 @@
@@ -3,17 +3,22 @@
"stage": "stable",
"stage": "stable",
"code": true,
"code": true,
"product": "Scraplandia",
"product": "Scraplandia",
- "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; better-sqlite3",
+ "runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js + Havok; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"domain": "openvibe.games, play.openvibe.games",
- "contracts": "openvibe-contracts v0.71.0 (apps/server/package.json)",
- "capabilities": ["games.mod.manage", "games.mod.read", "games.world.announce", "games.prop.place"],
+ "contracts": "openvibe-contracts v0.71.0, openvibe-sdk v0.22.0, openvibe-shared v1.30.1 (apps/server/package.json)",
+ "capabilities": [
+ "games.mod.manage",
+ "games.mod.read",
+ "games.world.announce",
+ "games.prop.place"
+ ],
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
"features": [
"features": [
"authoritative multiplayer physics sandbox (Havok on the server)",
"authoritative multiplayer physics sandbox (Havok on the server)",
"Source-style movement, physgun building, gathering, crafting, skills",
"Source-style movement, physgun building, gathering, crafting, skills",
- "persistent world, characters and props in SQLite",
+ "persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@1) with per-install grants and audit",
"declarative mods (games-content@1) with per-install grants and audit",
"players keyed by Network subjects",
"players keyed by Network subjects",
@@ -22,5 +27,5 @@
@@ -22,5 +27,5 @@
"the OpenVibe Frame from its own pinned copy",
"the OpenVibe Frame from its own pinned copy",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits)"
"per-actor limits on capability-boundary writes (openvibe-sdk/limits)"
],
],
- "updated": "2026-09-28"
+ "updated": "2026-09-29"
}
}
@@ -83,7 +83,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
@@ -83,7 +83,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
env: {
env: {
...process.env,
...process.env,
PORT: String(PORT),
PORT: String(PORT),
- DB_PATH: join(dir, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: dir,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
MAP_PATH: mapPath,
EDITOR_KEY: 'test-admin-key',
EDITOR_KEY: 'test-admin-key',
@@ -696,7 +699,10 @@ async function withMap(
@@ -696,7 +699,10 @@ async function withMap(
env: {
env: {
...process.env,
...process.env,
PORT: String(port),
PORT: String(port),
- DB_PATH: join(d, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: d,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
EDITOR_KEY: 'test-admin-key',
EDITOR_KEY: 'test-admin-key',
@@ -768,7 +774,10 @@ section('O. Live static reconciliation reaches the running server')
@@ -768,7 +774,10 @@ section('O. Live static reconciliation reaches the running server')
env: {
env: {
...process.env,
...process.env,
PORT: String(port),
PORT: String(port),
- DB_PATH: join(d, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: d,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
EDITOR_KEY: 'test-admin-key',
EDITOR_KEY: 'test-admin-key',
@@ -1022,7 +1031,10 @@ section('U. Live reconciliation touches only what changed')
@@ -1022,7 +1031,10 @@ section('U. Live reconciliation touches only what changed')
env: {
env: {
...process.env,
...process.env,
PORT: String(port),
PORT: String(port),
- DB_PATH: join(d, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: d,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
EDITOR_KEY: 'test-admin-key',
EDITOR_KEY: 'test-admin-key',
@@ -64,7 +64,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
@@ -64,7 +64,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
env: {
env: {
...process.env,
...process.env,
PORT: String(PORT),
PORT: String(PORT),
- DB_PATH: join(dir, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: dir,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
MAP_PATH: mapPath,
EDITOR_KEY: KEY,
EDITOR_KEY: KEY,
@@ -22,7 +22,10 @@ function startServer(): Promise<void> {
@@ -22,7 +22,10 @@ function startServer(): Promise<void> {
env: {
env: {
...process.env,
...process.env,
PORT: String(PORT),
PORT: String(PORT),
- DB_PATH: join(dir, 'world.db'),
+ NODE_ENV: 'test',
+ DATABASE_DIR: dir,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
@@ -95,14 +98,16 @@ async function main(): Promise<void> {
@@ -95,14 +98,16 @@ async function main(): Promise<void> {
// Look down to check the first-person body (drive input tracker directly).
// Look down to check the first-person body (drive input tracker directly).
await page.evaluate(() => {
await page.evaluate(() => {
- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = -1.2
+ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =
+ -1.2
})
})
await page.waitForTimeout(700)
await page.waitForTimeout(700)
await shot(page, '04-look-down-body')
await shot(page, '04-look-down-body')
console.log('phase: physgun E2E (walk to crates, grab, verify no freeze)')
console.log('phase: physgun E2E (walk to crates, grab, verify no freeze)')
await page.evaluate(() => {
await page.evaluate(() => {
- const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } }).__openvibe
+ const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } })
+ .__openvibe
w.input.pitch = 0
w.input.pitch = 0
w.input.yaw = 0
w.input.yaw = 0
})
})
@@ -185,7 +190,8 @@ async function main(): Promise<void> {
@@ -185,7 +190,8 @@ async function main(): Promise<void> {
])
])
console.log(` page responsive after grab: ${alive}`)
console.log(` page responsive after grab: ${alive}`)
const held = await page.evaluate(() => {
const held = await page.evaluate(() => {
- const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } }).__openvibe
+ const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } })
+ .__openvibe
return w.state.heldBy.size
return w.state.heldBy.size
})
})
console.log(` beams active (heldBy size): ${held}`)
console.log(` beams active (heldBy size): ${held}`)
@@ -193,7 +199,8 @@ async function main(): Promise<void> {
@@ -193,7 +199,8 @@ async function main(): Promise<void> {
// RMB while holding = freeze: held target goes 'frozen' and the beam drops.
// RMB while holding = freeze: held target goes 'frozen' and the beam drops.
const heldId = await page.evaluate(() => {
const heldId = await page.evaluate(() => {
- const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } }).__openvibe
+ const w = (window as unknown as { __openvibe: { state: { heldBy: Map<string, string> } } })
+ .__openvibe
return [...w.state.heldBy.keys()][0] ?? null
return [...w.state.heldBy.keys()][0] ?? null
})
})
await page.evaluate(() => {
await page.evaluate(() => {
@@ -242,7 +249,8 @@ async function main(): Promise<void> {
@@ -242,7 +249,8 @@ async function main(): Promise<void> {
await page.waitForTimeout(600)
await page.waitForTimeout(600)
// Lift the crate off the ground (friction fights the last few degrees).
// Lift the crate off the ground (friction fights the last few degrees).
await page.evaluate(() => {
await page.evaluate(() => {
- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = 0.45
+ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =
+ 0.45
})
})
await page.waitForTimeout(800)
await page.waitForTimeout(800)
await page.keyboard.down('KeyE')
await page.keyboard.down('KeyE')
@@ -310,7 +318,8 @@ async function main(): Promise<void> {
@@ -310,7 +318,8 @@ async function main(): Promise<void> {
// Beam fires even at nothing: aim at the sky, hold LMB, expect the dim
// Beam fires even at nothing: aim at the sky, hold LMB, expect the dim
// searching ray from the muzzle (GMod always-on beam).
// searching ray from the muzzle (GMod always-on beam).
await page.evaluate(() => {
await page.evaluate(() => {
- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = 0.5
+ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =
+ 0.5
})
})
await page.waitForTimeout(200)
await page.waitForTimeout(200)
await page.mouse.down()
await page.mouse.down()
@@ -320,7 +329,8 @@ async function main(): Promise<void> {
@@ -320,7 +329,8 @@ async function main(): Promise<void> {
void dbg
void dbg
await page.evaluate(() => {
await page.evaluate(() => {
- const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } }).__openvibe
+ const w = (window as unknown as { __openvibe: { input: { yaw: number; pitch: number } } })
+ .__openvibe
w.input.pitch = -0.15
w.input.pitch = -0.15
w.input.yaw = Math.PI
w.input.yaw = Math.PI
})
})
@@ -332,7 +342,8 @@ async function main(): Promise<void> {
@@ -332,7 +342,8 @@ async function main(): Promise<void> {
await page.keyboard.down('KeyC')
await page.keyboard.down('KeyC')
await page.waitForTimeout(900)
await page.waitForTimeout(900)
await page.evaluate(() => {
await page.evaluate(() => {
- ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch = -1.1
+ ;(window as unknown as { __openvibe: { input: { pitch: number } } }).__openvibe.input.pitch =
+ -1.1
})
})
await page.waitForTimeout(400)
await page.waitForTimeout(400)
await shot(page, '09-crouch-lookdown')
await shot(page, '09-crouch-lookdown')
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"dev": "tsx watch --env-file-if-exists=.env src/main.ts",
"start": "tsx src/main.ts",
"start": "tsx src/main.ts",
"build": "tsc --build",
"build": "tsc --build",
- "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:better-sqlite3 --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
+ "bundle": "esbuild src/main.ts --bundle --platform=node --format=esm --target=node22 --external:pg --external:iovalkey --external:@electric-sql/pglite --external:@babylonjs/havok --outfile=dist/server.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\""
},
},
"dependencies": {
"dependencies": {
"@babylonjs/havok": "^1.3.10",
"@babylonjs/havok": "^1.3.10",
@@ -17,16 +17,17 @@
@@ -17,16 +17,17 @@
"@openvibe/physics": "workspace:*",
"@openvibe/physics": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
+ "@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
- "better-sqlite3": "^11.9.1",
+ "iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",
+ "pg": "^8.23.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
},
},
"devDependencies": {
"devDependencies": {
- "@types/better-sqlite3": "^7.6.12",
"@types/ws": "^8.18.0",
"@types/ws": "^8.18.0",
"esbuild": "^0.25.2"
"esbuild": "^0.25.2"
}
}
@@ -32,7 +32,6 @@ import { encodeHeights } from '@openvibe/content'
@@ -32,7 +32,6 @@ import { encodeHeights } from '@openvibe/content'
const PORT = 18123
const PORT = 18123
const URL = `ws://127.0.0.1:${PORT}/ws`
const URL = `ws://127.0.0.1:${PORT}/ws`
const dir = mkdtempSync(join(tmpdir(), 'openvibe-slice-'))
const dir = mkdtempSync(join(tmpdir(), 'openvibe-slice-'))
-const dbPath = join(dir, 'world.db')
let server: ChildProcess | null = null
let server: ChildProcess | null = null
@@ -41,33 +40,45 @@ function startServer(): Promise<void> {
@@ -41,33 +40,45 @@ function startServer(): Promise<void> {
// map with the resource nodes its gameplay phases depend on.
// map with the resource nodes its gameplay phases depend on.
const FIX_SUB = 32
const FIX_SUB = 32
const flat = new Float32Array((FIX_SUB + 1) * (FIX_SUB + 1))
const flat = new Float32Array((FIX_SUB + 1) * (FIX_SUB + 1))
+ // A v2 document (map v1 was deleted): one authored flat terrain patch plus the
+ // resource nodes and props the gameplay phases depend on. Every document object
+ // carries a stable id.
const fixtureMap = {
const fixtureMap = {
- v: 1,
- halfExtent: 64,
- sub: FIX_SUB,
- heights: encodeHeights(flat),
+ v: 2,
+ terrains: [
+ {
+ id: 'ground',
+ name: 'slice ground',
+ pos: [0, 0, 0],
+ halfExtent: 64,
+ sub: FIX_SUB,
+ heights: encodeHeights(flat),
+ },
+ ],
statics: [],
statics: [],
nodes: [
nodes: [
- { node: 'oak_tree', pos: [24, 0, 24] },
- { node: 'branch_pile', pos: [-24, 0, 2] },
- { node: 'loose_stones', pos: [-24, 0, -2] },
- { node: 'scrap_pile', pos: [-28, 0, 4] },
- { node: 'berry_bush', pos: [22, 0, 18] },
+ { id: 'n-oak', node: 'oak_tree', pos: [24, 0, 24] },
+ { id: 'n-branch', node: 'branch_pile', pos: [-24, 0, 2] },
+ { id: 'n-stones', node: 'loose_stones', pos: [-24, 0, -2] },
+ { id: 'n-scrap', node: 'scrap_pile', pos: [-28, 0, 4] },
+ { id: 'n-berry', node: 'berry_bush', pos: [22, 0, 18] },
],
],
props: [
props: [
- { item: 'merchant_stall', pos: [12, 0.6, 12.8], yaw: 3.14 },
+ { id: 'pr-stall', item: 'merchant_stall', pos: [12, 0.6, 12.8], yaw: 3.14 },
// Stage 4: an authored production yard outside the north gate.
// Stage 4: an authored production yard outside the north gate.
- { item: 'sawmill', pos: [8, 0.7, 31], yaw: 0 },
- { item: 'cart_chassis', pos: [-6, 0.8, 30], yaw: 0 },
- { item: 'cart_wheel', pos: [-7.6, 0.6, 30], yaw: 0 },
- { item: 'cart_wheel', pos: [-4.4, 0.6, 30], yaw: 0 },
- { item: 'scrap_generator', pos: [10, 0.7, 31], yaw: 0 },
- { item: 'wooden_crate', pos: [2, 1.0, 25], yaw: 0.3 },
- { item: 'wooden_crate', pos: [2.2, 1.8, 25.1], yaw: 0.9 },
- { item: 'wooden_crate', pos: [-2, 1.0, 27], yaw: 0.1 },
- { item: 'metal_barrel', pos: [-1, 1.0, 24], yaw: 0 },
- { item: 'metal_barrel', pos: [-4, 1.0, 29], yaw: 0 },
+ { id: 'pr-sawmill', item: 'sawmill', pos: [8, 0.7, 31], yaw: 0 },
+ { id: 'pr-chassis', item: 'cart_chassis', pos: [-6, 0.8, 30], yaw: 0 },
+ { id: 'pr-wheel-a', item: 'cart_wheel', pos: [-7.6, 0.6, 30], yaw: 0 },
+ { id: 'pr-wheel-b', item: 'cart_wheel', pos: [-4.4, 0.6, 30], yaw: 0 },
+ { id: 'pr-generator', item: 'scrap_generator', pos: [10, 0.7, 31], yaw: 0 },
+ { id: 'pr-crate-a', item: 'wooden_crate', pos: [2, 1.0, 25], yaw: 0.3 },
+ { id: 'pr-crate-b', item: 'wooden_crate', pos: [2.2, 1.8, 25.1], yaw: 0.9 },
+ { id: 'pr-crate-c', item: 'wooden_crate', pos: [-2, 1.0, 27], yaw: 0.1 },
+ { id: 'pr-barrel-a', item: 'metal_barrel', pos: [-1, 1.0, 24], yaw: 0 },
+ { id: 'pr-barrel-b', item: 'metal_barrel', pos: [-4, 1.0, 29], yaw: 0 },
],
],
+ lights: [],
+ zones: [],
}
}
const mapPath = join(tmpdir(), `hq-slice-map-${Date.now()}.json`)
const mapPath = join(tmpdir(), `hq-slice-map-${Date.now()}.json`)
writeFileSync(mapPath, JSON.stringify(fixtureMap))
writeFileSync(mapPath, JSON.stringify(fixtureMap))
@@ -75,8 +86,13 @@ function startServer(): Promise<void> {
@@ -75,8 +86,13 @@ function startServer(): Promise<void> {
server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
env: {
env: {
...process.env,
...process.env,
+ // Embedded PGlite under this temp dir (no DATABASE_URL in development); the restart test
+ // reuses the same directory, so the world survives the restart.
+ NODE_ENV: 'test',
PORT: String(PORT),
PORT: String(PORT),
- DB_PATH: dbPath,
+ DATABASE_DIR: dir,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
EVENT_INTERVAL_SCALE: '0.05',
EVENT_INTERVAL_SCALE: '0.05',
MAP_PATH: mapPath,
MAP_PATH: mapPath,
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+import { existsSync } from 'node:fs'
+import { dirname, isAbsolute, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { MIGRATIONS_DIR } from '@openvibe/persistence'
+import { loadConfig } from './config.js'
+
+/**
+ * `pnpm dev:server` runs with cwd apps/server, so a cwd-relative migrations default applied 0
+ * migrations there and the server died on the first query. The default is the persistence package's
+ * absolute directory; GAMES_MIGRATIONS_DIR still overrides it.
+ */
+
+/** apps/server (the dev cwd), from this test file's location. */
+const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
+
+const originalCwd = process.cwd()
+afterEach(() => process.chdir(originalCwd))
+
+describe('loadConfig migrationsDir', () => {
+ it('defaults to the persistence package absolute migrations dir', () => {
+ const { migrationsDir } = loadConfig({}).db
+ expect(migrationsDir).toBe(MIGRATIONS_DIR)
+ expect(isAbsolute(migrationsDir)).toBe(true)
+ expect(migrationsDir.endsWith('migrations')).toBe(true)
+ })
+
+ it('lets GAMES_MIGRATIONS_DIR win', () => {
+ const { migrationsDir } = loadConfig({ GAMES_MIGRATIONS_DIR: '/somewhere/else' }).db
+ expect(migrationsDir).toBe('/somewhere/else')
+ })
+
+ it('resolves from any cwd, like apps/server as the dev cwd', () => {
+ process.chdir(SERVER_DIR)
+ const { migrationsDir } = loadConfig({}).db
+ expect(migrationsDir).toBe(MIGRATIONS_DIR)
+ expect(existsSync(join(migrationsDir, '0001_initial.sql'))).toBe(true)
+ })
+})
@@ -1,7 +1,29 @@
@@ -1,7 +1,29 @@
+import { MIGRATIONS_DIR } from '@openvibe/persistence'
+
+export interface DbConfig {
+ /** Pooled PostgreSQL URL (through PgBouncer); null in development, where an embedded PGlite is used. */
+ url: string | null
+ /** Owner-role direct URL migrations run on; required when `url` is set. */
+ directUrl: string | null
+ /** DATABASE_DIR: where the development PGlite database lives (default ./data). */
+ dataDir: string
+ /**
+ * The SQL migrations directory. Defaults to the persistence package's absolute migrations dir, so it
+ * resolves the same from any working directory; GAMES_MIGRATIONS_DIR overrides it.
+ */
+ migrationsDir: string
+ isProduction: boolean
+ /** Shared Valkey (limits, and M4 leases); null = in-process counters. */
+ valkeyUrl: string | null
+ valkeyPrefix: string
+}
+
export interface ServerConfig {
export interface ServerConfig {
port: number
port: number
host: string
host: string
- dbPath: string
+ /** GAMES_PLACE_ID: the place every world row on this instance belongs to. */
+ placeId: string
+ db: DbConfig
/** Directory of built client assets to serve, or null for API/WS only. */
/** Directory of built client assets to serve, or null for API/WS only. */
staticDir: string | null
staticDir: string | null
mapPath: string
mapPath: string
@@ -88,7 +110,16 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -88,7 +110,16 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
return {
return {
port: intEnv(env, 'PORT', 8000),
port: intEnv(env, 'PORT', 8000),
host: env.HOST ?? '0.0.0.0',
host: env.HOST ?? '0.0.0.0',
- dbPath: env.DB_PATH ?? 'data/world.db',
+ placeId: env.GAMES_PLACE_ID ?? 'scraplandia',
+ db: {
+ url: env.DATABASE_URL ?? null,
+ directUrl: env.DATABASE_DIRECT_URL ?? null,
+ dataDir: env.DATABASE_DIR ?? 'data',
+ migrationsDir: env.GAMES_MIGRATIONS_DIR ?? MIGRATIONS_DIR,
+ isProduction: env.NODE_ENV === 'production',
+ valkeyUrl: env.VALKEY_URL ?? null,
+ valkeyPrefix: env.VALKEY_PREFIX ?? 'ov:games:',
+ },
staticDir: env.STATIC_DIR ?? null,
staticDir: env.STATIC_DIR ?? null,
mapPath: env.MAP_PATH ?? 'data/map.json',
mapPath: env.MAP_PATH ?? 'data/map.json',
/** Fallback admin secret for the map editor (until openvibe.network SSO). */
/** Fallback admin secret for the map editor (until openvibe.network SSO). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+import { mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { createDb, type Db } from 'openvibe-sdk/db'
+import type { Logger } from '@openvibe/shared'
+import type { DbConfig } from './config.js'
+
+/**
+ * The serving handle (ADR-0007 decision 5, ADR-035): DATABASE_URL through PgBouncer, or an embedded
+ * PGlite database under DATABASE_DIR in development. Migrations run first, as the owner
+ * (DATABASE_DIRECT_URL) against real PostgreSQL, or on the embedded handle.
+ */
+export async function openDb(config: DbConfig, log: Logger): Promise<Db> {
+ const sdkLog = {
+ warn: (m: string) => log.warn(m),
+ error: (m: string) => log.error(m),
+ log: (m: string) => log.info(m),
+ }
+ if (!config.url) {
+ if (config.isProduction) {
+ throw new Error('DATABASE_URL is not set: production serves from PostgreSQL')
+ }
+ const dir = join(config.dataDir, 'pglite')
+ log.warn('DATABASE_URL unset: embedded PGlite database (development only, one process)', {
+ dir,
+ })
+ mkdirSync(dir, { recursive: true })
+ const db = createDb({ pglite: dir, service: 'games', log: sdkLog })
+ await db.migrate({ dir: config.migrationsDir, log: sdkLog })
+ return db
+ }
+ if (!config.directUrl) {
+ throw new Error(
+ 'DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection',
+ )
+ }
+ const owner = createDb({ url: config.directUrl, service: 'games-migrate', max: 1, log: sdkLog })
+ try {
+ await owner.migrate({ dir: config.migrationsDir, log: sdkLog })
+ } finally {
+ await owner.close()
+ }
+ return createDb({ url: config.url, service: 'games', log: sdkLog })
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,457 @@
@@ -0,0 +1,457 @@
+/**
+ * The write-behind flusher (ADR-0007 decision 5): the tick hands over a copy-on-write snapshot and
+ * never awaits I/O; one transaction runs at a time; a failed transaction restores the dirtiness; a
+ * disconnect save lands after an earlier checkpoint of the same character; shutdown drains.
+ */
+import { describe, expect, it } from 'vitest'
+import { createContent } from '@openvibe/content'
+import { openTestStore } from '@openvibe/persistence/testing'
+import type {
+ ConstraintDto,
+ PersistenceStore,
+ PlayerDto,
+ WorldEntityDto,
+} from '@openvibe/persistence'
+import type { ConstraintId, BodyId, PhysicsWorld } from '@openvibe/physics'
+import { createConsoleLogger } from '@openvibe/shared'
+import { quat, vec3 } from '@openvibe/shared'
+import { sql, type Tx } from 'openvibe-sdk/db'
+import { createPgOutbox } from 'openvibe-sdk/events'
+import { ServerMetrics } from '../observability/metrics.js'
+import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'
+import type { EventPlayer, ProgressSnapshot } from '../platform/gameEvents.js'
+import { RegionTracker } from '@openvibe/gameplay'
+import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'
+import { GameWorld } from './gameWorld.js'
+import { NpcManager } from './npcManager.js'
+
+const log = createConsoleLogger({ app: 'flusher-test' }, 'error')
+
+/** A PhysicsWorld that keeps no simulation — enough to build a GameWorld and spawn entities. */
+function stubPhysics(): PhysicsWorld {
+ let n = 0
+ const id = (p: string) => `${p}${++n}` as unknown as BodyId
+ return {
+ step: () => {},
+ addBody: () => id('b'),
+ removeBody: () => {},
+ getTransform: () => {},
+ setTransform: () => {},
+ setMotionType: () => {},
+ getLinearVelocity: () => {},
+ setLinearVelocity: () => {},
+ getAngularVelocity: () => {},
+ setAngularVelocity: () => {},
+ isSettled: () => true,
+ wake: () => {},
+ addConstraint: () => `c${++n}` as unknown as ConstraintId,
+ removeConstraint: () => {},
+ setConstraintMotor: () => {},
+ applyForce: () => {},
+ raycast: () => null,
+ sweepCapsule: () => null,
+ dispose: () => {},
+ }
+}
+
+function entityDto(id: string): WorldEntityDto {
+ return {
+ id,
+ kind: 'prop',
+ defId: 'wooden_crate',
+ ownerId: null,
+ pos: [1, 2, 3],
+ rot: [0, 0, 0, 1],
+ motion: 'frozen',
+ state: null,
+ updatedAt: 1,
+ }
+}
+
+function constraintDto(id: string): ConstraintDto {
+ return { id, type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 1 }
+}
+
+function playerDto(id: string, name: string, token = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'): PlayerDto {
+ return {
+ id,
+ token,
+ name,
+ pos: [0, 1, 0],
+ yaw: 0,
+ inventory: { size: 24, hotbar: 6, slots: [] },
+ skills: {},
+ friends: [],
+ appearance: null,
+ charSlot: 0,
+ armor: null,
+ reputation: {},
+ unlocks: [],
+ activeJob: null,
+ stats: null,
+ updatedAt: 1,
+ }
+}
+
+const fakeSession = {} as unknown as FlushCharacter['session']
+const eventPlayerOf = (id: string, name: string, subjectId: string | null = null): EventPlayer => ({
+ playerId: id,
+ slot: 0,
+ name,
+ subjectId,
+})
+const emptyProgress: ProgressSnapshot = { levels: {}, unlocks: [] }
+
+function character(dto: PlayerDto): FlushCharacter {
+ return {
+ session: fakeSession,
+ dto,
+ player: eventPlayerOf(dto.id, dto.name, dto.subjectId ?? null),
+ progress: emptyProgress,
+ }
+}
+
+function payload(over: Partial<FlushPayload> = {}): FlushPayload {
+ return {
+ reason: 'checkpoint',
+ leaving: false,
+ worldSaved: false,
+ meta: {},
+ entityUpserts: [],
+ entityDeletes: [],
+ npcUpserts: [],
+ constraintUpserts: [],
+ constraintDeletes: [],
+ characters: [],
+ worldSavedCount: 0,
+ restore: () => {},
+ ...over,
+ }
+}
+
+/** Wraps a store, delaying every transaction so a checkpoint's job stays in flight. */
+function slowStore(store: PersistenceStore, ms: number): PersistenceStore {
+ const wrapped = Object.create(store) as PersistenceStore
+ Object.defineProperty(wrapped, 'transaction', {
+ value: async <T>(fn: (t: Tx) => Promise<T>): Promise<T> => {
+ await new Promise((r) => setTimeout(r, ms))
+ return store.transaction(fn)
+ },
+ })
+ return wrapped
+}
+
+/** Counts transactions and lets one (or the next) fail. */
+function flakyStore(
+ store: PersistenceStore,
+ fail: () => boolean,
+): { store: PersistenceStore; txCount: () => number } {
+ let count = 0
+ const wrapped = Object.create(store) as PersistenceStore
+ Object.defineProperty(wrapped, 'transaction', {
+ value: async <T>(fn: (t: Tx) => Promise<T>): Promise<T> => {
+ count++
+ if (fail()) throw new Error('db down')
+ return store.transaction(fn)
+ },
+ })
+ return { store: wrapped, txCount: () => count }
+}
+
+function fakeEvents() {
+ let id = 0
+ let seq = 0
+ return {
+ prepare: (envelope: Record<string, unknown>) => ({ ...envelope, event_id: `evt_${++id}` }),
+ publish: async (envelopes: unknown) => {
+ const list = Array.isArray(envelopes) ? envelopes : [envelopes]
+ return {
+ results: list.map((e) => ({ event_id: (e as { event_id: string }).event_id, seq: ++seq })),
+ }
+ },
+ }
+}
+
+describe('write-behind flusher', () => {
+ it('a final (shutdown) checkpoint behind one in flight is queued, not coalesced', async () => {
+ const store = await openTestStore()
+ const flusher = new Flusher(slowStore(store, 100), new ServerMetrics(), log, undefined)
+ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('early')] }))
+ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('coalesced')] }))
+ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('final')] }), { final: true })
+ expect(await flusher.drain()).toBe(true)
+ const ids = (await store.worldEntities.loadAll()).map((e) => e.id).sort()
+ expect(ids).toEqual(['early', 'final'])
+ })
+
+ it('drain gives up at its deadline while a transaction hangs', async () => {
+ const store = await openTestStore()
+ const flusher = new Flusher(slowStore(store, 2000), new ServerMetrics(), log, undefined)
+ flusher.checkpoint(() => payload({ entityUpserts: [entityDto('slow')] }))
+ const t0 = performance.now()
+ expect(await flusher.drain(100)).toBe(false)
+ expect(performance.now() - t0).toBeLessThan(1000)
+ expect(await flusher.drain()).toBe(true)
+ })
+
+ it('a failed disconnect save is retried in place until it lands', async () => {
+ const store = await openTestStore()
+ let failuresLeft = 2
+ const { store: flaky, txCount } = flakyStore(store, () => failuresLeft-- > 0)
+ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)
+ flusher.savePlayer(
+ payload({ characters: [character(playerDto('p-retry', 'Retry'))], leaving: true }),
+ () => false,
+ [10, 10, 10],
+ )
+ expect(await flusher.drain()).toBe(true)
+ expect(txCount()).toBe(3)
+ expect((await store.players.findById('p-retry'))?.name).toBe('Retry')
+ })
+
+ it('a failed disconnect save stops retrying once the character is back online', async () => {
+ const store = await openTestStore()
+ const { store: flaky, txCount } = flakyStore(store, () => true)
+ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)
+ let restored = 0
+ flusher.savePlayer(
+ payload({ characters: [character(playerDto('p-back', 'Back'))], restore: () => restored++ }),
+ () => true,
+ [10, 10, 10],
+ )
+ expect(await flusher.drain()).toBe(true)
+ expect(txCount()).toBe(1)
+ expect(restored).toBe(1)
+ })
+
+ it('(a) checkpoint returns synchronously while a slow transaction is pending', async () => {
+ const store = await openTestStore()
+ const metrics = new ServerMetrics()
+ const flusher = new Flusher(slowStore(store, 150), metrics, log, undefined)
+ let built = 0
+ const t0 = performance.now()
+ flusher.checkpoint(() => {
+ built++
+ return payload({ entityUpserts: [entityDto('e1')] })
+ })
+ const elapsed = performance.now() - t0
+ expect(elapsed).toBeLessThan(50) // returned before the transaction finished
+ expect(built).toBe(1)
+ expect(flusher.pending).toBe(1)
+ await flusher.drain()
+ expect(await store.worldEntities.loadAll()).toHaveLength(1)
+ expect(metrics.persistRowsWritten).toBe(1)
+ })
+
+ it('(b) a failed transaction restores dirtiness and the next checkpoint writes it', async () => {
+ const store = await openTestStore()
+ let failuresLeft = 1
+ const { store: flaky } = flakyStore(store, () => failuresLeft-- > 0)
+ const flusher = new Flusher(flaky, new ServerMetrics(), log, undefined)
+
+ const restored: string[] = []
+ let sessionsDirty = 0
+ // The snapshot's rows (a real GameWorld clears its dirty flags and put them back in restore; the
+ // contract tested here is that the flusher calls restore on failure and retries from a fresh build).
+ const build = (): FlushPayload =>
+ payload({
+ entityUpserts: [entityDto('e1')],
+ entityDeletes: ['gone'],
+ constraintUpserts: [constraintDto('c1')],
+ characters: [character(playerDto('p1', 'Scrapper'))],
+ restore: () => {
+ restored.push('restore')
+ sessionsDirty++
+ },
+ })
+
+ flusher.checkpoint(build)
+ await flusher.drain()
+ expect(restored).toEqual(['restore'])
+ expect(sessionsDirty).toBe(1)
+ expect(await store.worldEntities.loadAll()).toHaveLength(0)
+
+ // The next checkpoint carries the restored rows.
+ flusher.checkpoint(build)
+ await flusher.drain()
+ expect((await store.worldEntities.loadAll()).map((e) => e.id)).toEqual(['e1'])
+ expect((await store.constraints.loadAll()).map((c) => c.id)).toEqual(['c1'])
+ expect((await store.players.findById('p1'))?.name).toBe('Scrapper')
+ expect(restored).toEqual(['restore']) // a successful flush does not restore
+ })
+
+ it('(c) coalesces checkpoints: at most one extra transaction', async () => {
+ const store = await openTestStore()
+ const { store: counted, txCount } = flakyStore(store, () => false)
+ const flusher = new Flusher(slowStore(counted, 100), new ServerMetrics(), log, undefined)
+ let built = 0
+ const build = () => {
+ built++
+ return payload({ entityUpserts: [entityDto(`e${built}`)] })
+ }
+ flusher.checkpoint(build)
+ flusher.checkpoint(build) // coalesced: build not called, dirty flags stay set
+ flusher.checkpoint(build)
+ expect(built).toBe(1)
+ await flusher.drain()
+ expect(txCount()).toBe(1)
+
+ flusher.checkpoint(build) // queue empty again: one more transaction at most
+ await flusher.drain()
+ expect(txCount()).toBe(2)
+ expect(built).toBe(2)
+ })
+
+ it('(d) a disconnect save queued after a checkpoint of the same character lands last', async () => {
+ const store = await openTestStore()
+ const flusher = new Flusher(slowStore(store, 50), new ServerMetrics(), log, undefined)
+ flusher.checkpoint(() => payload({ characters: [character(playerDto('p1', 'before'))] }))
+ flusher.savePlayer(payload({ characters: [character(playerDto('p1', 'after'))] }))
+ await flusher.drain()
+ expect((await store.players.findById('p1'))?.name).toBe('after')
+ })
+
+ it('(e) drain writes everything a shutdown checkpoint carries', async () => {
+ const store = await openTestStore()
+ const flusher = new Flusher(store, new ServerMetrics(), log, undefined)
+ flusher.checkpoint(() =>
+ payload({
+ reason: 'shutdown',
+ entityUpserts: [entityDto('e1'), entityDto('e2')],
+ npcUpserts: [entityDto('npc1')],
+ constraintUpserts: [constraintDto('c1')],
+ characters: [character(playerDto('p1', 'Scrapper'))],
+ }),
+ )
+ expect(await flusher.drain()).toBe(true)
+ expect(await store.worldEntities.loadAll()).toHaveLength(3)
+ expect(await store.constraints.loadAll()).toHaveLength(1)
+ expect(await store.players.findById('p1')).not.toBeNull()
+ })
+
+ it('(f) outbox rows commit with the world rows and roll back with them', async () => {
+ const store = await openTestStore()
+ const outbox = createPgOutbox(store.db, { events: fakeEvents() as never })
+ const sink = outboxSink(outbox, store.db)
+ const recorder = new GameEventRecorder(sink, 'testworld', 0, () => 1)
+ const flusher = new Flusher(store, new ServerMetrics(), log, recorder)
+ const rows = async (): Promise<number> =>
+ Number(await store.db.value(sql`SELECT count(*) FROM event_outbox`))
+
+ // Rollback: the character row and its games.player.left event commit together or not at all.
+ await expect(
+ store.transaction(async (t) => {
+ await store.players.upsertMany([playerDto('p1', 'Scrapper')])
+ await recorder.recordLeave(t, eventPlayerOf('p1', 'Scrapper'))
+ throw new Error('boom')
+ }),
+ ).rejects.toThrow('boom')
+ expect(await rows()).toBe(0)
+ expect(await store.players.findById('p1')).toBeNull()
+
+ // Commit: a leaving save writes both.
+ flusher.savePlayer(
+ payload({ leaving: true, characters: [character(playerDto('p1', 'Scrapper'))] }),
+ )
+ await flusher.drain()
+ expect(await store.players.findById('p1')).not.toBeNull()
+ expect(await rows()).toBe(1)
+
+ // A failed transaction writes neither.
+ let fail = true
+ const { store: flaky } = flakyStore(store, () => {
+ const now = fail
+ fail = false
+ return now
+ })
+ const failing = new Flusher(flaky, new ServerMetrics(), log, recorder)
+ failing.savePlayer(
+ payload({ leaving: true, characters: [character(playerDto('p2', 'Other'))] }),
+ )
+ await failing.drain()
+ expect(await store.players.findById('p2')).toBeNull()
+ expect(await rows()).toBe(1)
+ })
+
+ it('(g) guest keys are hashed: a raw guest token never appears in characters', async () => {
+ const store = await openTestStore()
+ const flusher = new Flusher(store, new ServerMetrics(), log, undefined)
+ const raw = 'guestbrowsertoken0123456789abcdef'
+ flusher.savePlayer(payload({ characters: [character(playerDto('g1', 'Guest', raw))] }))
+ await flusher.drain()
+ const row = await store.db.maybe<{ account_key: string }>(
+ sql`SELECT account_key FROM characters WHERE id = 'g1'`,
+ )
+ expect(row?.account_key).toMatch(/^guest:[0-9a-f]{64}$/)
+ expect(JSON.stringify(row)).not.toContain(raw)
+ expect((await store.players.findByToken(raw))?.id).toBe('g1')
+ })
+
+ it('(h) NPCs write only what changed', async () => {
+ const store = await openTestStore()
+ const content = createContent()
+ const world = { content } as unknown as GameWorld
+ const npcs = new NpcManager(
+ world,
+ new RegionTracker(32),
+ {
+ onAttackPlayer: () => {},
+ soundsThisTick: () => [],
+ hostilesNear: () => [],
+ },
+ log,
+ )
+ await npcs.seedOrRestore(store)
+ const boot = npcs.takeDirty(1000)
+ expect(boot.length).toBeGreaterThan(0)
+ // Nothing changed since: nothing to write.
+ expect(npcs.takeDirty(1000)).toEqual([])
+ // One NPC takes a hit: exactly that one is written.
+ const victim = boot[0]!.id
+ expect(npcs.damage(victim as never, 1, 1000)).toBe('hurt')
+ const changed = npcs.takeDirty(1000)
+ expect(changed.map((d) => d.id)).toEqual([victim])
+ // restoreDirty puts a failed flush's ids back.
+ npcs.restoreDirty(changed.map((d) => d.id))
+ expect(npcs.takeDirty(1000).map((d) => d.id)).toEqual([victim])
+ })
+})
+
+describe('GameWorld dirty snapshot', () => {
+ it('takeDirty clears flags and restoreDirty puts entities, deletes and constraints back', async () => {
+ const store = await openTestStore()
+ const world = new GameWorld(createContent(), stubPhysics(), log)
+ const a = world.spawnProp({
+ defId: 'wooden_crate',
+ pos: vec3(1, 1, 1),
+ rot: quat(),
+ motion: 'frozen',
+ })
+ const b = world.spawnProp({
+ defId: 'metal_barrel',
+ pos: vec3(2, 1, 2),
+ rot: quat(),
+ motion: 'frozen',
+ })
+ world.addConstraintRecord(a, b, 'weld', {}, 'c1')
+
+ const first = world.takeDirty()
+ expect(first.entityUpserts.map((e) => e.id).sort()).toEqual([a.id, b.id].sort())
+ expect(first.constraintUpserts.map((c) => c.id)).toEqual(['c1'])
+ expect(world.takeDirty().entityUpserts).toEqual([])
+
+ world.restoreDirty(first)
+ const again = world.takeDirty()
+ expect(again.entityUpserts.map((e) => e.id).sort()).toEqual([a.id, b.id].sort())
+ expect(again.constraintUpserts.map((c) => c.id)).toEqual(['c1'])
+
+ // Deletes: a despawned entity is re-armed only while it is really gone.
+ world.despawn(a.id)
+ const del = world.takeDirty()
+ expect(del.entityDeletes).toEqual([a.id])
+ world.restoreDirty(del)
+ expect(world.takeDirty().entityDeletes).toEqual([a.id])
+ await world.flushDirty(store)
+ expect(world.takeDirty().entityDeletes).toEqual([])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+import type {
+ ConstraintDto,
+ PersistenceStore,
+ PlayerDto,
+ WorldEntityDto,
+} from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { ServerMetrics } from '../observability/metrics.js'
+import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
+import type { PlayerSession } from './playerSession.js'
+
+/**
+ * The write-behind flusher (ADR-0007 decision 5). The tick never awaits I/O: it takes a
+ * copy-on-write snapshot of the dirty rows (clearing the flags as it copies) and hands it here, then
+ * carries on. One serialized queue per instance runs at most one database transaction at a time.
+ *
+ * checkpoint(build) called on the tick; if a checkpoint is already queued or running the new one
+ * is coalesced — `build` is never called, so the dirty flags stay set and the
+ * next checkpoint carries those rows. `{ final: true }` (shutdown) is never
+ * coalesced: behind an in-flight checkpoint it is queued and built when it runs
+ * (the tick has stopped, so building later sees the same state).
+ * savePlayer(payload) a disconnect save, always queued, so it lands after an earlier checkpoint of
+ * the same character. A failure is retried in place with backoff (the session is
+ * gone, so there is no dirty flag to restore) unless the character came back.
+ * drain(timeout) resolves when the queue is empty, or false at the deadline (shutdown, and a
+ * returning player's sign-in, so it reads its own last save).
+ *
+ * A failed transaction restores the snapshot's dirtiness (`payload.restore`): nothing is lost
+ * silently, the failure is logged and counted, and the next checkpoint retries.
+ */
+export interface FlushCharacter {
+ session: PlayerSession
+ dto: PlayerDto
+ player: EventPlayer
+ progress: ProgressSnapshot
+}
+
+export interface FlushPayload {
+ reason: 'checkpoint' | 'shutdown'
+ /** A disconnect save also records games.player.left in the same transaction. */
+ leaving: boolean
+ /** Whether a games.world.saved checkpoint event belongs to this flush. */
+ worldSaved: boolean
+ meta: Record<string, unknown>
+ entityUpserts: readonly WorldEntityDto[]
+ entityDeletes: readonly string[]
+ npcUpserts: readonly WorldEntityDto[]
+ constraintUpserts: readonly ConstraintDto[]
+ constraintDeletes: readonly string[]
+ characters: readonly FlushCharacter[]
+ worldSavedCount: number
+ /** Puts the snapshot's dirtiness back after a failed transaction. */
+ restore: () => void
+}
+
+export class Flusher {
+ private chain: Promise<void> = Promise.resolve()
+ private checkpointActive = false
+ private failures = 0
+ private coalesced = 0
+ private pendingJobs = 0
+
+ constructor(
+ private readonly store: PersistenceStore,
+ private readonly metrics: ServerMetrics,
+ private readonly log: Logger,
+ private readonly recorder: GameEventRecorder | undefined,
+ ) {}
+
+ /** Queued or running jobs. */
+ get pending(): number {
+ return this.pendingJobs
+ }
+
+ /**
+ * Synchronous entry point for the tick. Returns immediately; when a checkpoint is already in
+ * flight this one is coalesced (the builder is not called, so nothing is cleared).
+ */
+ checkpoint(build: () => FlushPayload | null, { final = false }: { final?: boolean } = {}): void {
+ if (this.checkpointActive && final) {
+ void this.enqueue(async () => {
+ const payload = build()
+ if (!payload) return
+ try {
+ await this.run(payload)
+ } catch (err) {
+ this.countFailure(err)
+ payload.restore()
+ }
+ })
+ return
+ }
+ if (this.checkpointActive) {
+ this.coalesced++
+ this.metrics.persistCoalesced = this.coalesced
+ return
+ }
+ const payload = build()
+ if (!payload) return
+ this.checkpointActive = true
+ void this.enqueue(async () => {
+ try {
+ await this.run(payload)
+ } catch (err) {
+ this.countFailure(err)
+ payload.restore()
+ } finally {
+ this.checkpointActive = false
+ }
+ })
+ }
+
+ /**
+ * A character save queued behind any in-flight checkpoint; never coalesced. `returned()` says
+ * whether the character is online again (its session then carries the state, and a retry would
+ * write an older copy over it).
+ */
+ savePlayer(
+ payload: FlushPayload,
+ returned: () => boolean = () => false,
+ retryDelaysMs = [1000, 2000, 4000, 8000],
+ ): void {
+ void this.enqueue(async () => {
+ for (let attempt = 0; ; attempt++) {
+ try {
+ await this.run(payload)
+ return
+ } catch (err) {
+ this.countFailure(err)
+ if (returned() || attempt >= retryDelaysMs.length) {
+ payload.restore()
+ if (!returned())
+ this.log.error(
+ 'a character save failed for good: its changes since the last checkpoint are lost',
+ {
+ characters: payload.characters.map((c) => c.dto.id).join(','),
+ },
+ )
+ return
+ }
+ await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]))
+ }
+ }
+ })
+ }
+
+ /** Resolves true when the queue is empty, false when it is not empty by `timeoutMs`. */
+ async drain(timeoutMs = 25_000): Promise<boolean> {
+ const deadline = Date.now() + timeoutMs
+ for (;;) {
+ const current = this.chain
+ const left = deadline - Date.now()
+ if (left <= 0) return false
+ let timer: ReturnType<typeof setTimeout> | undefined
+ const timedOut = await Promise.race([
+ current.then(() => false),
+ new Promise<boolean>((r) => {
+ timer = setTimeout(() => r(true), left)
+ }),
+ ])
+ clearTimeout(timer)
+ if (timedOut) return false
+ if (current === this.chain) return true
+ }
+ }
+
+ private enqueue(job: () => Promise<void>): Promise<void> {
+ this.pendingJobs++
+ this.metrics.dbDirtyQueue = this.pendingJobs
+ const next = this.chain.then(job)
+ // The chain never rejects: a failed job is handled by its own catch, and the next job must run.
+ this.chain = next.then(
+ () => {},
+ () => {},
+ )
+ return next.finally(() => {
+ this.pendingJobs--
+ this.metrics.dbDirtyQueue = this.pendingJobs
+ })
+ }
+
+ private async run(payload: FlushPayload): Promise<void> {
+ const t0 = performance.now()
+ const afterCommit: (() => void)[] = []
+ await this.store.transaction(async (t) => {
+ for (const [key, value] of Object.entries(payload.meta)) {
+ await this.store.meta.set(key, value)
+ }
+ if (payload.npcUpserts.length > 0)
+ await this.store.worldEntities.upsertMany(payload.npcUpserts)
+ if (payload.entityUpserts.length > 0)
+ await this.store.worldEntities.upsertMany(payload.entityUpserts)
+ if (payload.entityDeletes.length > 0)
+ await this.store.worldEntities.deleteMany(payload.entityDeletes)
+ if (payload.constraintUpserts.length > 0)
+ await this.store.constraints.upsertMany(payload.constraintUpserts)
+ if (payload.constraintDeletes.length > 0)
+ await this.store.constraints.deleteMany(payload.constraintDeletes)
+ if (payload.characters.length > 0)
+ await this.store.players.upsertMany(payload.characters.map((c) => c.dto))
+ if (this.recorder) {
+ afterCommit.push(
+ await this.recorder.recordPlayersSaved(
+ t,
+ payload.characters.map((c) => ({ player: c.player, progress: c.progress })),
+ ),
+ )
+ if (payload.leaving) {
+ for (const c of payload.characters)
+ afterCommit.push(await this.recorder.recordLeave(t, c.player))
+ }
+ if (payload.worldSaved) {
+ afterCommit.push(
+ await this.recorder.recordWorldSaved(
+ t,
+ payload.worldSavedCount,
+ payload.characters.length,
+ payload.reason,
+ ),
+ )
+ }
+ }
+ })
+ for (const done of afterCommit) done()
+ this.metrics.persistRowsWritten =
+ payload.npcUpserts.length +
+ payload.entityUpserts.length +
+ payload.entityDeletes.length +
+ payload.constraintUpserts.length +
+ payload.constraintDeletes.length +
+ payload.characters.length
+ this.metrics.persistFlushMs = performance.now() - t0
+ }
+
+ private countFailure(err: unknown): void {
+ this.failures++
+ this.metrics.persistFlushFailures = this.failures
+ this.log.error('persistence flush failed: rows stay dirty and will be retried', {
+ error: String((err as Error | undefined)?.message ?? err),
+ })
+ }
+}
@@ -98,6 +98,7 @@ import {
@@ -98,6 +98,7 @@ import {
import { buildSnapshot, updateInterest, wireEntityFor } from './replication.js'
import { buildSnapshot, updateInterest, wireEntityFor } from './replication.js'
import { NpcManager } from './npcManager.js'
import { NpcManager } from './npcManager.js'
import { EventManager } from './eventManager.js'
import { EventManager } from './eventManager.js'
+import { Flusher, type FlushCharacter, type FlushPayload } from './flusher.js'
/** A network connection as the game sees it — transport-agnostic. */
/** A network connection as the game sees it — transport-agnostic. */
export interface GameConnection {
export interface GameConnection {
@@ -107,6 +108,8 @@ export interface GameConnection {
@@ -107,6 +108,8 @@ export interface GameConnection {
const MOVE = DEFAULT_MOVEMENT
const MOVE = DEFAULT_MOVEMENT
const MAX_INPUT_QUEUE = 6
const MAX_INPUT_QUEUE = 6
+/** The five weather kinds a persisted env_weather may name. */
+const WEATHERS = new Set<WeatherKind>(['clear', 'cloudy', 'rain', 'storm', 'fog'])
/**
/**
* Platform adapters the server drives (roadmap Wave 12). Both optional: the
* Platform adapters the server drives (roadmap Wave 12). Both optional: the
@@ -141,6 +144,12 @@ export class GameServer {
@@ -141,6 +144,12 @@ export class GameServer {
private readonly heldEntityIds = new Set<string>()
private readonly heldEntityIds = new Set<string>()
/** Short-lived cache of OFFLINE owners' friend lists (prop protection). */
/** Short-lived cache of OFFLINE owners' friend lists (prop protection). */
private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()
private readonly offlineFriendsCache = new Map<string, { friends: Set<string>; at: number }>()
+ /** Offline-owner lookups in flight, so the tick never issues the same read twice. */
+ private readonly friendsLoadInFlight = new Set<string>()
+ /** Connections whose hello is still resolving (a second one while it is refused). */
+ private readonly hellosInFlight = new Set<GameConnection>()
+ /** Write-behind persistence: one transaction in flight, the tick never awaits it. */
+ private readonly flusher: Flusher
private tick = 0
private tick = 0
private readonly moveQueries: CollisionQueries
private readonly moveQueries: CollisionQueries
/** Body excluded from the current movement sweep (the moving player's own). */
/** Body excluded from the current movement sweep (the moving player's own). */
@@ -172,20 +181,9 @@ export class GameServer {
@@ -172,20 +181,9 @@ export class GameServer {
private readonly log: Logger,
private readonly log: Logger,
private readonly integrations: GameIntegrations = {},
private readonly integrations: GameIntegrations = {},
) {
) {
- // The world clock and weather survive restarts (a rainy dusk stays a
- // rainy dusk).
- const savedTime = Number(store.meta.get('env_time') ?? Number.NaN)
- this.env = createEnvironment(Number.isFinite(savedTime) ? savedTime : 0.34)
- const savedWeather = store.meta.get('env_weather')
- if (
- savedWeather === 'clear' ||
- savedWeather === 'cloudy' ||
- savedWeather === 'rain' ||
- savedWeather === 'storm' ||
- savedWeather === 'fog'
- ) {
- this.env.weather = savedWeather as WeatherKind
- }
+ // The world clock and weather survive restarts (a rainy dusk stays a rainy dusk); they are
+ // loaded asynchronously in load(), before the tick loop starts.
+ this.env = createEnvironment(0.34)
this.npcs = new NpcManager(
this.npcs = new NpcManager(
world,
world,
this.regions,
this.regions,
@@ -222,7 +220,6 @@ export class GameServer {
@@ -222,7 +220,6 @@ export class GameServer {
},
},
log.child({ system: 'npc' }),
log.child({ system: 'npc' }),
)
)
- this.npcs.seedOrRestore(store)
this.events = new EventManager(
this.events = new EventManager(
world,
world,
@@ -249,6 +246,13 @@ export class GameServer {
@@ -249,6 +246,13 @@ export class GameServer {
log.child({ system: 'events' }),
log.child({ system: 'events' }),
)
)
+ this.flusher = new Flusher(
+ store,
+ metrics,
+ log.child({ system: 'persist' }),
+ integrations.events,
+ )
+
// Players block players: sweeps include the Player layer, minus the
// Players block players: sweeps include the Player layer, minus the
// mover's own kinematic body.
// mover's own kinematic body.
this.moveQueries = {
this.moveQueries = {
@@ -264,6 +268,32 @@ export class GameServer {
@@ -264,6 +268,32 @@ export class GameServer {
}
}
}
}
+ /**
+ * Async boot: world clock/weather, market stock and the NPC population are read from PostgreSQL.
+ * main.ts awaits this before starting the tick loop.
+ */
+ async load(): Promise<void> {
+ const rawTime = await this.store.meta.get('env_time')
+ const savedTime = Number(rawTime ?? Number.NaN)
+ if (Number.isFinite(savedTime)) this.env.timeOfDay = savedTime
+ const savedWeather = await this.store.meta.get('env_weather')
+ if (typeof savedWeather === 'string' && WEATHERS.has(savedWeather as WeatherKind)) {
+ this.env.weather = savedWeather as WeatherKind
+ }
+ for (const { key, value } of await this.store.meta.list('market_')) {
+ const market = new Map<string, { stock: number; at: number }>()
+ if (value && typeof value === 'object') {
+ for (const [item, state] of Object.entries(
+ value as Record<string, { stock: number; at: number }>,
+ )) {
+ market.set(item, state)
+ }
+ }
+ this.marketStock.set(key.slice('market_'.length), market)
+ }
+ await this.npcs.seedOrRestore(this.store)
+ }
+
get currentTick(): number {
get currentTick(): number {
return this.tick
return this.tick
}
}
@@ -282,10 +312,25 @@ export class GameServer {
@@ -282,10 +312,25 @@ export class GameServer {
if (cached && Date.now() - cached.at < 30_000) {
if (cached && Date.now() - cached.at < 30_000) {
return cached.friends.has(session.playerId)
return cached.friends.has(session.playerId)
}
}
- const owner = this.store.players.findById(entity.owner)
- const friends = new Set(owner?.friends ?? [])
- this.offlineFriendsCache.set(entity.owner, { friends, at: Date.now() })
- return friends.has(session.playerId)
+ // Offline owner, no cached answer: start an async load and DENY this tick. The tick never awaits
+ // I/O; the cached answer is used from the next tick on (the same 30 s TTL).
+ const owner = entity.owner
+ if (!this.friendsLoadInFlight.has(owner)) {
+ this.friendsLoadInFlight.add(owner)
+ void this.store.players
+ .findById(owner)
+ .then((found) => {
+ this.offlineFriendsCache.set(owner, {
+ friends: new Set(found?.friends ?? []),
+ at: Date.now(),
+ })
+ })
+ .catch((err: unknown) => {
+ this.log.warn('offline owner lookup failed', { owner, error: String(err) })
+ })
+ .finally(() => this.friendsLoadInFlight.delete(owner))
+ }
+ return false
}
}
// ── Connection lifecycle ───────────────────────────────────────────
// ── Connection lifecycle ───────────────────────────────────────────
@@ -293,8 +338,15 @@ export class GameServer {
@@ -293,8 +338,15 @@ export class GameServer {
onMessage(conn: GameConnection, msg: ClientMessage): void {
onMessage(conn: GameConnection, msg: ClientMessage): void {
const session = this.sessionsByConn.get(conn)
const session = this.sessionsByConn.get(conn)
if (!session) {
if (!session) {
- if (msg.t === 'hello') void this.handleHello(conn, msg)
- else conn.close(4001, 'hello_first')
+ if (msg.t === 'hello') {
+ // A second hello while the first is still resolving (it awaits Network) is refused, so two
+ // frames cannot both create a session for one connection.
+ if (this.hellosInFlight.has(conn)) {
+ conn.close(4008, 'hello_in_flight')
+ return
+ }
+ void this.handleHello(conn, msg)
+ } else conn.close(4001, 'hello_first')
return
return
}
}
switch (msg.t) {
switch (msg.t) {
@@ -461,7 +513,7 @@ export class GameServer {
@@ -461,7 +513,7 @@ export class GameServer {
break
break
}
}
case 'trust': {
case 'trust': {
- this.handleTrust(session, msg.player, msg.trusted)
+ void this.handleTrust(session, msg.player, msg.trusted)
break
break
}
}
case 'constraint': {
case 'constraint': {
@@ -778,17 +830,8 @@ export class GameServer {
@@ -778,17 +830,8 @@ export class GameServer {
): { stock: number; at: number } {
): { stock: number; at: number } {
let market = this.marketStock.get(marketId)
let market = this.marketStock.get(marketId)
if (!market) {
if (!market) {
- // Restore from the persisted blob once per market.
+ // Persisted market blobs are loaded at boot (GameServer.load); an unknown market starts fresh.
market = new Map()
market = new Map()
- const raw = this.store.meta.get(`market_${marketId}`)
- if (raw) {
- try {
- const parsed = JSON.parse(raw) as Record<string, { stock: number; at: number }>
- for (const [item, s] of Object.entries(parsed)) market.set(item, s)
- } catch {
- /* corrupted blob: fall through to fresh stock */
- }
- }
this.marketStock.set(marketId, market)
this.marketStock.set(marketId, market)
}
}
let state = market.get(entry.item)
let state = market.get(entry.item)
@@ -803,12 +846,6 @@ export class GameServer {
@@ -803,12 +846,6 @@ export class GameServer {
return state
return state
}
}
- private persistMarkets(): void {
- for (const [id, market] of this.marketStock) {
- this.store.meta.set(`market_${id}`, JSON.stringify(Object.fromEntries(market)))
- }
- }
-
/**
/**
* Market interactions: open/buy/sell against the market a shop prop
* Market interactions: open/buy/sell against the market a shop prop
* references. Everything server-atomic: proximity, faction stance,
* references. Everything server-atomic: proximity, faction stance,
@@ -1218,7 +1255,17 @@ export class GameServer {
@@ -1218,7 +1255,17 @@ export class GameServer {
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
}
}
+ /** Guards one hello per connection while it resolves (it awaits Network); see onMessage. */
private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {
private async handleHello(conn: GameConnection, msg: ClientHello): Promise<void> {
+ this.hellosInFlight.add(conn)
+ try {
+ await this.resolveHello(conn, msg)
+ } finally {
+ this.hellosInFlight.delete(conn)
+ }
+ }
+
+ private async resolveHello(conn: GameConnection, msg: ClientHello): Promise<void> {
if (msg.v !== PROTOCOL_VERSION) {
if (msg.v !== PROTOCOL_VERSION) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.close(4002, 'protocol_mismatch')
conn.close(4002, 'protocol_mismatch')
@@ -1254,7 +1301,7 @@ export class GameServer {
@@ -1254,7 +1301,7 @@ export class GameServer {
// Guest conversion (WS-B task 8): the character this browser played as a guest joins the
// Guest conversion (WS-B task 8): the character this browser played as a guest joins the
// account in its first free slot, once.
// account in its first free slot, once.
if (isGuestToken(msg.token)) {
if (isGuestToken(msg.token)) {
- const g = this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
+ const g = await this.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
if (g.moved > 0)
if (g.moved > 0)
this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
this.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
else if (g.full)
else if (g.full)
@@ -1275,7 +1322,9 @@ export class GameServer {
@@ -1275,7 +1322,9 @@ export class GameServer {
return
return
}
}
}
}
- const existing = this.store.players.findByTokenSlot(token, slot)
+ // A disconnect save of this character may still be queued: read after it lands, not before.
+ await this.flusher.drain(5000)
+ const existing = await this.store.players.findByTokenSlot(token, slot)
// One live session per CHARACTER; other characters of the same account
// One live session per CHARACTER; other characters of the same account
// may stay online (an account still only plays one at a time in
// may stay online (an account still only plays one at a time in
// practice — same token kicks apply per slot).
// practice — same token kicks apply per slot).
@@ -1378,7 +1427,7 @@ export class GameServer {
@@ -1378,7 +1427,7 @@ export class GameServer {
})
})
this.sendInventory(session)
this.sendInventory(session)
this.sendSkills(session)
this.sendSkills(session)
- this.sendFriends(session)
+ await this.sendFriends(session)
this.send(session, this.timeWire())
this.send(session, this.timeWire())
this.sendReputation(session)
this.sendReputation(session)
this.metrics.sessions = this.sessions.size
this.metrics.sessions = this.sessions.size
@@ -1390,9 +1439,9 @@ export class GameServer {
@@ -1390,9 +1439,9 @@ export class GameServer {
const stored = existing
const stored = existing
? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks)
? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks)
: { levels: {}, unlocks: [] }
: { levels: {}, unlocks: [] }
- this.store.transaction(() =>
- recorder.recordJoin(eventPlayer(session), stored, existing !== null),
- )
+ await this.store.transaction(async (t) => {
+ await recorder.recordJoin(t, eventPlayer(session), stored, existing !== null)
+ })
}
}
this.log.info('player connected', {
this.log.info('player connected', {
playerId: playerId as string,
playerId: playerId as string,
@@ -1485,14 +1534,18 @@ export class GameServer {
@@ -1485,14 +1534,18 @@ export class GameServer {
}
}
}
}
- private handleTrust(session: PlayerSession, targetId: string, trusted: boolean): void {
+ private async handleTrust(
+ session: PlayerSession,
+ targetId: string,
+ trusted: boolean,
+ ): Promise<void> {
if (targetId === (session.playerId as string)) {
if (targetId === (session.playerId as string)) {
this.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })
this.send(session, { t: 'result', action: 'trust', ok: false, error: 'self' })
return
return
}
}
// Target must be a real player (online or persisted).
// Target must be a real player (online or persisted).
const online = this.sessions.get(targetId as PlayerId)
const online = this.sessions.get(targetId as PlayerId)
- const known = online ?? this.store.players.findById(targetId)
+ const known = online ?? (await this.store.players.findById(targetId))
if (!known) {
if (!known) {
this.send(session, { t: 'result', action: 'trust', ok: false, error: 'unknown_player' })
this.send(session, { t: 'result', action: 'trust', ok: false, error: 'unknown_player' })
return
return
@@ -1501,14 +1554,14 @@ export class GameServer {
@@ -1501,14 +1554,14 @@ export class GameServer {
else session.friends.delete(targetId)
else session.friends.delete(targetId)
session.dirty = true
session.dirty = true
this.send(session, { t: 'result', action: 'trust', ok: true })
this.send(session, { t: 'result', action: 'trust', ok: true })
- this.sendFriends(session)
+ await this.sendFriends(session)
}
}
- private sendFriends(session: PlayerSession): void {
+ private async sendFriends(session: PlayerSession): Promise<void> {
const friends: { id: string; name: string }[] = []
const friends: { id: string; name: string }[] = []
for (const id of session.friends) {
for (const id of session.friends) {
const online = this.sessions.get(id as PlayerId)
const online = this.sessions.get(id as PlayerId)
- const name = online?.name ?? this.store.players.findById(id)?.name ?? 'unknown'
+ const name = online?.name ?? (await this.store.players.findById(id))?.name ?? 'unknown'
friends.push({ id, name })
friends.push({ id, name })
}
}
this.send(session, { t: 'friends', friends })
this.send(session, { t: 'friends', friends })
@@ -2340,10 +2393,10 @@ export class GameServer {
@@ -2340,10 +2393,10 @@ export class GameServer {
// retracted on the first tick after the change).
// retracted on the first tick after the change).
this.integrations.mods?.tick(this.tick, this.modHost)
this.integrations.mods?.tick(this.tick, this.modHost)
- // 9. Periodic persistence flush.
+ // 9. Periodic persistence checkpoint (write-behind: returns immediately).
if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {
if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {
this.lastFlushTick = this.tick
this.lastFlushTick = this.tick
- this.flush()
+ this.checkpoint()
}
}
this.metrics.tick = this.tick
this.metrics.tick = this.tick
@@ -2484,62 +2537,113 @@ export class GameServer {
@@ -2484,62 +2537,113 @@ export class GameServer {
// ── Persistence ────────────────────────────────────────────────────
// ── Persistence ────────────────────────────────────────────────────
/**
/**
- * One transaction per flush: world rows, player rows and the outbox
- * events describing them commit together (or not at all).
+ * The tick's persistence call: builds the snapshot synchronously and hands it to the write-behind
+ * flusher. It returns immediately; a checkpoint already in flight coalesces this one.
*/
*/
- flush(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {
- const recorder = this.integrations.events
- const dirty: PlayerSession[] = []
- const afterCommit = this.store.transaction(() => {
- // World clock + weather ride along with every flush (tiny meta writes).
- this.store.meta.set('env_time', String(this.env.timeOfDay))
- this.store.meta.set('env_weather', this.env.weather)
- this.persistMarkets()
- this.npcs.flush(this.store, Date.now())
- const wrote = this.world.flushDirty(this.store)
- const dirtyPlayers: PlayerDto[] = []
- for (const session of this.sessions.values()) {
- if (!session.dirty) continue
- dirtyPlayers.push(this.playerToDto(session))
- dirty.push(session)
- }
- if (dirtyPlayers.length > 0) this.store.players.upsertMany(dirtyPlayers)
- if (wrote > 0 || dirtyPlayers.length > 0) {
- this.log.debug('persistence flush', { entities: wrote, players: dirtyPlayers.length })
- }
- if (!recorder) return []
- return [
- recorder.recordPlayersSaved(
- dirty.map((sess) => ({ player: eventPlayer(sess), progress: sessionProgress(sess) })),
- ),
- recorder.recordWorldSaved(wrote, dirtyPlayers.length, reason),
- ]
- })
- for (const session of dirty) session.dirty = false
- for (const done of afterCommit) done()
- this.metrics.dbDirtyQueue = 0
+ private checkpoint(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {
+ this.flusher.checkpoint(() => this.buildFlushPayload(reason))
+ }
+
+ /**
+ * Copy-on-write snapshot of everything dirty (clearing the flags as it copies). One transaction
+ * writes meta, NPC/entity upserts and deletes, constraints, characters and the outbox events
+ * describing them; on failure the snapshot's dirtiness is restored.
+ */
+ private buildFlushPayload(reason: 'checkpoint' | 'shutdown'): FlushPayload {
+ const now = Date.now()
+ const world = this.world.takeDirty()
+ const npcs = this.npcs.takeDirty(now)
+ const characters: FlushCharacter[] = []
+ for (const session of this.sessions.values()) {
+ if (!session.dirty) continue
+ session.dirty = false
+ characters.push(this.flushCharacter(session))
+ }
+ // World clock + weather and the market blobs ride along with every checkpoint.
+ const meta: Record<string, unknown> = {
+ env_time: this.env.timeOfDay,
+ env_weather: this.env.weather,
+ }
+ for (const [id, market] of this.marketStock) meta[`market_${id}`] = Object.fromEntries(market)
+ if (world.entityUpserts.length > 0 || characters.length > 0) {
+ this.log.debug('persistence checkpoint', {
+ entities: world.entityUpserts.length,
+ npcs: npcs.length,
+ players: characters.length,
+ })
+ }
+ return {
+ reason,
+ leaving: false,
+ worldSaved: true,
+ meta,
+ entityUpserts: world.entityUpserts,
+ entityDeletes: world.entityDeletes,
+ npcUpserts: npcs,
+ constraintUpserts: world.constraintUpserts,
+ constraintDeletes: world.constraintDeletes,
+ characters,
+ worldSavedCount: world.entityUpserts.length,
+ restore: () => {
+ this.world.restoreDirty(world)
+ this.npcs.restoreDirty(npcs.map((n) => n.id))
+ // A disconnected session was saved by its own queued save, which runs after this one.
+ for (const c of characters) {
+ if (this.sessions.has(c.session.playerId)) c.session.dirty = true
+ }
+ },
+ }
+ }
+
+ private flushCharacter(session: PlayerSession): FlushCharacter {
+ return {
+ session,
+ dto: this.playerToDto(session),
+ player: eventPlayer(session),
+ progress: sessionProgress(session),
+ }
}
}
- /** Full save on shutdown. */
+ /** Full save on shutdown; main.ts awaits drain() after this. */
shutdown(): void {
shutdown(): void {
for (const session of this.sessions.values()) session.dirty = true
for (const session of this.sessions.values()) session.dirty = true
- this.flush('shutdown')
+ // Never coalesced: behind a checkpoint still in flight this one is queued, not dropped.
+ this.flusher.checkpoint(() => this.buildFlushPayload('shutdown'), { final: true })
this.log.info('world saved on shutdown', {})
this.log.info('world saved on shutdown', {})
}
}
- /** Saves one character; `leaving` also records games.player.left in the same transaction. */
+ /** Resolves when the write-behind queue is empty (shutdown). */
+ async drain(timeoutMs = 25_000): Promise<boolean> {
+ return this.flusher.drain(timeoutMs)
+ }
+
+ /**
+ * Saves one character; `leaving` also records games.player.left in the same transaction. The DTO is
+ * built synchronously and the transaction is queued on the same queue as checkpoints, so a
+ * disconnect save always lands after an earlier checkpoint of that character. The caller does not
+ * await it.
+ */
private savePlayer(session: PlayerSession, leaving = false): void {
private savePlayer(session: PlayerSession, leaving = false): void {
- const recorder = this.integrations.events
- const afterCommit = this.store.transaction(() => {
- this.store.players.upsert(this.playerToDto(session))
- if (!recorder) return []
- const player = eventPlayer(session)
- const done = [recorder.recordPlayersSaved([{ player, progress: sessionProgress(session) }])]
- if (leaving) done.push(recorder.recordLeave(player))
- return done
- })
session.dirty = false
session.dirty = false
- for (const done of afterCommit) done()
+ this.flusher.savePlayer(
+ {
+ reason: 'checkpoint',
+ leaving,
+ worldSaved: false,
+ meta: {},
+ entityUpserts: [],
+ entityDeletes: [],
+ npcUpserts: [],
+ constraintUpserts: [],
+ constraintDeletes: [],
+ characters: [this.flushCharacter(session)],
+ worldSavedCount: 0,
+ restore: () => {
+ if (this.sessions.has(session.playerId)) session.dirty = true
+ },
+ },
+ () => this.sessions.has(session.playerId),
+ )
}
}
/**
/**
@@ -576,22 +576,22 @@ export class GameWorld {
@@ -576,22 +576,22 @@ export class GameWorld {
// ── Persistence mapping ────────────────────────────────────────────
// ── Persistence mapping ────────────────────────────────────────────
- seedOrRestore(store: PersistenceStore): void {
+ async seedOrRestore(store: PersistenceStore): Promise<void> {
const world = this.content.world
const world = this.content.world
- const seeded = store.meta.get('world_seeded') === 'yes'
- const worldChanged = store.meta.get('world_id') !== world.id
+ const seeded = (await store.meta.get('world_seeded')) === true
+ const worldChanged = (await store.meta.get('world_id')) !== world.id
if (!seeded) {
if (!seeded) {
this.seedProps(store)
this.seedProps(store)
this.seedResources(store)
this.seedResources(store)
- store.meta.set('world_seeded', 'yes')
- store.meta.set('world_id', world.id)
- this.flushDirty(store)
+ await store.meta.set('world_seeded', true)
+ await store.meta.set('world_id', world.id)
+ await this.flushDirty(store)
this.log.info('world seeded', { world: world.id, entities: this.entities.size })
this.log.info('world seeded', { world: world.id, entities: this.entities.size })
return
return
}
}
- const rows = store.worldEntities.loadAll()
+ const rows = await store.worldEntities.loadAll()
let restored = 0
let restored = 0
for (const row of rows) {
for (const row of rows) {
if (row.kind === 'resource' && worldChanged) continue // re-seeded below
if (row.kind === 'resource' && worldChanged) continue // re-seeded below
@@ -600,7 +600,7 @@ export class GameWorld {
@@ -600,7 +600,7 @@ export class GameWorld {
// Constraints restore after entities; dangling/invalid records are
// Constraints restore after entities; dangling/invalid records are
// pruned (an unknown type from a future build, or a despawned prop).
// pruned (an unknown type from a future build, or a despawned prop).
- const constraintRows = store.constraints.loadAll()
+ const constraintRows = await store.constraints.loadAll()
const pruned: string[] = []
const pruned: string[] = []
for (const row of constraintRows) {
for (const row of constraintRows) {
const a = this.entities.get(row.entityA as EntityId)
const a = this.entities.get(row.entityA as EntityId)
@@ -619,16 +619,16 @@ export class GameWorld {
@@ -619,16 +619,16 @@ export class GameWorld {
pruned.push(row.id)
pruned.push(row.id)
}
}
}
}
- if (pruned.length > 0) store.constraints.deleteMany(pruned)
+ if (pruned.length > 0) await store.constraints.deleteMany(pruned)
if (worldChanged) {
if (worldChanged) {
// The map definition changed: resource layout follows the new world,
// The map definition changed: resource layout follows the new world,
// player constructions persist, players go back to spawn.
// player constructions persist, players go back to spawn.
- store.worldEntities.deleteByKind('resource')
+ await store.worldEntities.deleteByKind('resource')
this.seedResources(store)
this.seedResources(store)
- store.players.resetAllPositions(worldSpawn(world).pos, worldSpawn(world).yaw)
- store.meta.set('world_id', world.id)
- this.flushDirty(store)
+ await store.players.resetAllPositions(worldSpawn(world).pos, worldSpawn(world).yaw)
+ await store.meta.set('world_id', world.id)
+ await this.flushDirty(store)
this.log.info('world definition changed — resources re-seeded, players respawned', {
this.log.info('world definition changed — resources re-seeded, players respawned', {
world: world.id,
world: world.id,
})
})
@@ -910,46 +910,93 @@ export class GameWorld {
@@ -910,46 +910,93 @@ export class GameWorld {
return false
return false
}
}
- flushDirty(store: PersistenceStore): number {
- const dirty: WorldEntityDto[] = []
+ /**
+ * Copy-on-write snapshot of everything dirty, clearing the flags and sets as it copies. Taken
+ * synchronously on the tick; the write-behind flusher persists it in one transaction. A failed
+ * flush calls `restoreDirty` so nothing is lost.
+ */
+ takeDirty(): WorldDirtySnapshot {
const now = Date.now()
const now = Date.now()
+ const entityUpserts: WorldEntityDto[] = []
for (const entity of this.entities.all()) {
for (const entity of this.entities.all()) {
// Players persist via their repository; NPCs via the NpcManager
// Players persist via their repository; NPCs via the NpcManager
// (their abstract state, never the materialized entity).
// (their abstract state, never the materialized entity).
if (!entity.dirty || !entity.persistent) continue
if (!entity.dirty || !entity.persistent) continue
if (entity.kind === 'player' || entity.kind === 'npc') continue
if (entity.kind === 'player' || entity.kind === 'npc') continue
- dirty.push(entityToDto(entity, now))
+ entityUpserts.push(entityToDto(entity, now))
entity.dirty = false
entity.dirty = false
}
}
- if (dirty.length > 0) store.worldEntities.upsertMany(dirty)
- if (this.deletedIds.size > 0) {
- store.worldEntities.deleteMany([...this.deletedIds])
- this.deletedIds.clear()
- }
- if (this.constraintsDirty.size > 0) {
- const dtos: ConstraintDto[] = []
- for (const id of this.constraintsDirty) {
- const rec = this.constraintRecords.get(id)
- if (rec) {
- dtos.push({
- id: rec.id,
- type: rec.type,
- entityA: rec.a,
- entityB: rec.b,
- params: rec.params as Record<string, unknown>,
- updatedAt: now,
- })
- }
+ const entityDeletes = [...this.deletedIds]
+ this.deletedIds.clear()
+
+ const constraintUpserts: ConstraintDto[] = []
+ for (const id of this.constraintsDirty) {
+ const rec = this.constraintRecords.get(id)
+ if (rec) {
+ constraintUpserts.push({
+ id: rec.id,
+ type: rec.type,
+ entityA: rec.a,
+ entityB: rec.b,
+ params: rec.params as Record<string, unknown>,
+ updatedAt: now,
+ })
}
}
- store.constraints.upsertMany(dtos)
- this.constraintsDirty.clear()
}
}
- if (this.constraintsDeleted.size > 0) {
- store.constraints.deleteMany([...this.constraintsDeleted])
- this.constraintsDeleted.clear()
+ this.constraintsDirty.clear()
+ const constraintDeletes = [...this.constraintsDeleted]
+ this.constraintsDeleted.clear()
+
+ return {
+ entityUpserts,
+ entityDeletes,
+ constraintUpserts,
+ constraintDeletes,
+ dirtyEntityIds: entityUpserts.map((e) => e.id),
+ dirtyConstraintIds: constraintUpserts.map((c) => c.id),
+ }
+ }
+
+ /** Puts back what a failed flush took: rows still present are dirty again, deletes re-armed. */
+ restoreDirty(snapshot: WorldDirtySnapshot): void {
+ for (const id of snapshot.dirtyEntityIds) {
+ const entity = this.entities.get(id as EntityId)
+ if (entity) entity.dirty = true
+ }
+ for (const id of snapshot.entityDeletes) {
+ if (!this.entities.get(id as EntityId)) this.deletedIds.add(id as EntityId)
+ }
+ for (const id of snapshot.dirtyConstraintIds) {
+ if (this.constraintRecords.has(id)) this.constraintsDirty.add(id)
+ }
+ for (const id of snapshot.constraintDeletes) {
+ if (!this.constraintRecords.has(id)) this.constraintsDeleted.add(id)
}
}
- return dirty.length
}
}
+
+ /** Boot/seed convenience: take everything dirty and write it in one call (never on the tick). */
+ async flushDirty(store: PersistenceStore): Promise<number> {
+ const snapshot = this.takeDirty()
+ if (snapshot.entityUpserts.length > 0)
+ await store.worldEntities.upsertMany(snapshot.entityUpserts)
+ if (snapshot.entityDeletes.length > 0)
+ await store.worldEntities.deleteMany(snapshot.entityDeletes)
+ if (snapshot.constraintUpserts.length > 0)
+ await store.constraints.upsertMany(snapshot.constraintUpserts)
+ if (snapshot.constraintDeletes.length > 0)
+ await store.constraints.deleteMany(snapshot.constraintDeletes)
+ return snapshot.entityUpserts.length
+ }
+}
+
+/** What `takeDirty` hands the flusher; `restoreDirty` can put it all back on failure. */
+export interface WorldDirtySnapshot {
+ entityUpserts: WorldEntityDto[]
+ entityDeletes: string[]
+ constraintUpserts: ConstraintDto[]
+ constraintDeletes: string[]
+ dirtyEntityIds: string[]
+ dirtyConstraintIds: string[]
}
}
function entityToDto(entity: GameEntity, now: number): WorldEntityDto {
function entityToDto(entity: GameEntity, now: number): WorldEntityDto {
@@ -51,6 +51,8 @@ export class NpcManager {
@@ -51,6 +51,8 @@ export class NpcManager {
private readonly states = new Map<string, NpcState>()
private readonly states = new Map<string, NpcState>()
/** Materialized subset: instance id -> physics body. */
/** Materialized subset: instance id -> physics body. */
private readonly bodies = new Map<string, BodyId>()
private readonly bodies = new Map<string, BodyId>()
+ /** Instances whose persisted abstract state changed since the last flush. */
+ private readonly dirtyIds = new Set<string>()
private readonly navigation: NavigationService = new StraightLineNavigation()
private readonly navigation: NavigationService = new StraightLineNavigation()
private behaviorPhase = 0
private behaviorPhase = 0
@@ -62,9 +64,10 @@ export class NpcManager {
@@ -62,9 +64,10 @@ export class NpcManager {
) {}
) {}
/** Boot: restore persisted NPCs, then seed content spawns not yet known. */
/** Boot: restore persisted NPCs, then seed content spawns not yet known. */
- seedOrRestore(store: PersistenceStore): void {
- const rows = store.worldEntities.loadAll().filter((r) => r.kind === 'npc')
+ async seedOrRestore(store: PersistenceStore): Promise<void> {
+ const rows = (await store.worldEntities.loadAll()).filter((r) => r.kind === 'npc')
for (const row of rows) {
for (const row of rows) {
+ this.dirtyIds.add(row.id)
const arch = this.world.content.npc(row.defId)
const arch = this.world.content.npc(row.defId)
if (!arch) continue
if (!arch) continue
const s = row.state ?? {}
const s = row.state ?? {}
@@ -108,6 +111,7 @@ export class NpcManager {
@@ -108,6 +111,7 @@ export class NpcManager {
lastAttackMs: 0,
lastAttackMs: 0,
respawnAt: 0,
respawnAt: 0,
})
})
+ this.dirtyIds.add(id)
}
}
this.log.info('npcs ready', { count: this.states.size })
this.log.info('npcs ready', { count: this.states.size })
}
}
@@ -130,6 +134,7 @@ export class NpcManager {
@@ -130,6 +134,7 @@ export class NpcManager {
const arch = npc ? this.world.content.npc(npc.archetype) : undefined
const arch = npc ? this.world.content.npc(npc.archetype) : undefined
if (!npc || !arch || npc.respawnAt > 0) return null
if (!npc || !arch || npc.respawnAt > 0) return null
npc.health -= amount
npc.health -= amount
+ this.dirtyIds.add(npc.id)
if (npc.health > 0) return 'hurt'
if (npc.health > 0) return 'hurt'
npc.respawnAt = nowMs + arch.respawnSeconds * 1000
npc.respawnAt = nowMs + arch.respawnSeconds * 1000
npc.health = 0
npc.health = 0
@@ -151,6 +156,7 @@ export class NpcManager {
@@ -151,6 +156,7 @@ export class NpcManager {
npc.x = npc.homeX
npc.x = npc.homeX
npc.z = npc.homeZ
npc.z = npc.homeZ
npc.state = 'idle'
npc.state = 'idle'
+ this.dirtyIds.add(npc.id)
}
}
const wantFull = npc.respawnAt === 0 && this.regions.isActive(npc.x, npc.z)
const wantFull = npc.respawnAt === 0 && this.regions.isActive(npc.x, npc.z)
if (wantFull && !materialized) this.materialize(npc)
if (wantFull && !materialized) this.materialize(npc)
@@ -230,6 +236,7 @@ export class NpcManager {
@@ -230,6 +236,7 @@ export class NpcManager {
npc.x += (dx / dist) * step
npc.x += (dx / dist) * step
npc.z += (dz / dist) * step
npc.z += (dz / dist) * step
npc.yaw = Math.atan2(dx, dz)
npc.yaw = Math.atan2(dx, dz)
+ this.dirtyIds.add(npc.id)
}
}
}
}
// Sync entity + body + spatial from the authoritative state.
// Sync entity + body + spatial from the authoritative state.
@@ -299,10 +306,16 @@ export class NpcManager {
@@ -299,10 +306,16 @@ export class NpcManager {
return hit === null
return hit === null
}
}
- /** Persistence: NPCs write their ABSTRACT state (never bodies/entities). */
- flush(store: PersistenceStore, now: number): void {
+ /**
+ * Copy-on-write snapshot of the NPCs whose abstract state changed, clearing the dirty set. Taken
+ * synchronously on the tick; only changed NPC rows are written, not the whole population.
+ */
+ takeDirty(now: number): WorldEntityDto[] {
+ if (this.dirtyIds.size === 0) return []
const dtos: WorldEntityDto[] = []
const dtos: WorldEntityDto[] = []
- for (const npc of this.states.values()) {
+ for (const id of this.dirtyIds) {
+ const npc = this.states.get(id)
+ if (!npc) continue
dtos.push({
dtos.push({
id: npc.id,
id: npc.id,
kind: 'npc',
kind: 'npc',
@@ -320,6 +333,12 @@ export class NpcManager {
@@ -320,6 +333,12 @@ export class NpcManager {
updatedAt: now,
updatedAt: now,
})
})
}
}
- if (dtos.length > 0) store.worldEntities.upsertMany(dtos)
+ this.dirtyIds.clear()
+ return dtos
+ }
+
+ /** A failed flush: mark these NPCs dirty again (only if they still exist). */
+ restoreDirty(ids: readonly string[]): void {
+ for (const id of ids) if (this.states.has(id)) this.dirtyIds.add(id)
}
}
}
}
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* The Wave 12 platform boundary exercised on the real authoritative server
* The Wave 12 platform boundary exercised on the real authoritative server
- * (headless Havok, real SQLite file, protocol-level connections), across a
- * restart:
+ * (headless Havok, real PostgreSQL schema through in-memory PGlite,
+ * protocol-level connections), across a restart:
*
*
* - a signed-in player is keyed by the canonical subject;
* - a signed-in player is keyed by the canonical subject;
* - a guest presenting an account key as its token is refused;
* - a guest presenting an account key as its token is refused;
@@ -17,7 +17,8 @@ import { mkdtempSync } from 'node:fs'
@@ -17,7 +17,8 @@ import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
+import { openPgStore, type PgPersistenceStore } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
import {
import {
PROTOCOL_VERSION,
PROTOCOL_VERSION,
@@ -26,12 +27,8 @@ import {
@@ -26,12 +27,8 @@ import {
type ServerMessage,
type ServerMessage,
} from '@openvibe/protocol'
} from '@openvibe/protocol'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
-import {
- createEventsClient,
- createOutbox,
- type Outbox,
- type SqliteDatabase,
-} from 'openvibe-sdk/events'
+import type { Db } from 'openvibe-sdk/db'
+import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadConfig } from '../config.js'
import { loadConfig } from '../config.js'
@@ -53,16 +50,17 @@ const STAFF: ModActor = { audit: SUBJECT, subject: { type: 'user', id: SUBJECT }
@@ -53,16 +50,17 @@ const STAFF: ModActor = { audit: SUBJECT, subject: { type: 'user', id: SUBJECT }
const log = createConsoleLogger({ app: 'test' }, 'error')
const log = createConsoleLogger({ app: 'test' }, 'error')
const dir = mkdtempSync(join(tmpdir(), 'openvibe-platform-'))
const dir = mkdtempSync(join(tmpdir(), 'openvibe-platform-'))
-const dbPath = join(dir, 'world.db')
const config = loadConfig({
const config = loadConfig({
- DB_PATH: dbPath,
MAP_PATH: join(dir, 'map.json'),
MAP_PATH: join(dir, 'map.json'),
OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
})
})
let havok: unknown
let havok: unknown
+/** One in-memory PGlite database for the whole file; the restart tests share its state. */
+let db: Db
beforeAll(async () => {
beforeAll(async () => {
havok = await loadHavok()
havok = await loadHavok()
+ db = await openTestDb()
// openvibe.network's /api/auth/me for the one signed-in test account.
// openvibe.network's /api/auth/me for the one signed-in test account.
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get('authorization')
const auth = new Headers(init?.headers).get('authorization')
@@ -76,20 +74,22 @@ afterAll(() => {
@@ -76,20 +74,22 @@ afterAll(() => {
})
})
interface Boot {
interface Boot {
- store: SqlitePersistenceStore
+ store: PgPersistenceStore
world: GameWorld
world: GameWorld
game: GameServer
game: GameServer
registry: ModRegistry
registry: ModRegistry
- outbox: Outbox
+ outbox: PgOutbox
dispose(): void
dispose(): void
}
}
-function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
+async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Boot> {
const content = createContent()
const content = createContent()
const physics = createHeadlessHavokWorld(havok)
const physics = createHeadlessHavokWorld(havok)
- const store = openSqliteStore(dbPath)
+ // The store rides on the shared database; close() is never called (the instance is shared).
+ const store = openPgStore(db, { placeId: config.placeId })
+ await store.ensurePlace()
const world = new GameWorld(content, physics, log)
const world = new GameWorld(content, physics, log)
- world.seedOrRestore(store)
+ await world.seedOrRestore(store)
const pc = createPlatformClient(
const pc = createPlatformClient(
{
{
...config.platform,
...config.platform,
@@ -99,18 +99,21 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
@@ -99,18 +99,21 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
},
},
platform.fetch,
platform.fetch,
)!
)!
- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
+ const outbox = createPgOutbox(db, {
events: createEventsClient(pc.client, { source: 'games' }),
events: createEventsClient(pc.client, { source: 'games' }),
})
})
- outbox.ensureSchema()
- const sink = outboxSink(outbox)
- const registry = new ModRegistry(store, content, sink)
- const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 30 })
+ const sink = outboxSink(outbox, db)
+ const registry = new ModRegistry(store, content, sink, Date.now, log.child({ system: 'mods' }))
+ await registry.load()
+ const runtime = new ModRuntime(registry, content, log.child({ system: 'mods' }), {
+ reconcileEveryTicks: 30,
+ })
const recorder = new GameEventRecorder(sink, content.world.id, 0)
const recorder = new GameEventRecorder(sink, content.world.id, 0)
const game = new GameServer(config, world, store, new ServerMetrics(), log, {
const game = new GameServer(config, world, store, new ServerMetrics(), log, {
events: recorder,
events: recorder,
mods: runtime,
mods: runtime,
})
})
+ await game.load()
return {
return {
store,
store,
world,
world,
@@ -118,7 +121,6 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
@@ -118,7 +121,6 @@ function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
registry,
registry,
outbox,
outbox,
dispose() {
dispose() {
- store.close()
physics.dispose()
physics.dispose()
},
},
}
}
@@ -165,10 +167,10 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -165,10 +167,10 @@ describe('Games on platform identity, events and mods (real server, restart)', (
},
},
})
})
let signedInPlayerId = ''
let signedInPlayerId = ''
- let timeBefore = ''
+ let timeBefore = 0
it('runs identity, mods and events against the live world', async () => {
it('runs identity, mods and events against the live world', async () => {
- const a = boot(platform)
+ const a = await boot(platform)
// A guest cannot claim an account key as its token.
// A guest cannot claim an account key as its token.
const intruder = connect(a.game)
const intruder = connect(a.game)
@@ -181,14 +183,16 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -181,14 +183,16 @@ describe('Games on platform identity, events and mods (real server, restart)', (
const joined = await ana.hello('abcdefgh12', 'tok-ana')
const joined = await ana.hello('abcdefgh12', 'tok-ana')
expect(joined.welcome).toBeDefined()
expect(joined.welcome).toBeDefined()
signedInPlayerId = (joined.welcome as { playerId: string }).playerId
signedInPlayerId = (joined.welcome as { playerId: string }).playerId
- a.game.flush()
- expect(a.store.players.findById(signedInPlayerId)).toMatchObject({
+ // The tick never awaits I/O; shutdown queues the final checkpoint, drain waits for it.
+ a.game.shutdown()
+ await a.game.drain()
+ expect(await a.store.players.findById(signedInPlayerId)).toMatchObject({
token: SUBJECT,
token: SUBJECT,
subjectId: SUBJECT,
subjectId: SUBJECT,
})
})
// Mod A: props granted, announcements requested but denied.
// Mod A: props granted, announcements requested but denied.
- a.registry.install(
+ await a.registry.install(
{
{
manifest: sampleManifest({ id: MOD_A }),
manifest: sampleManifest({ id: MOD_A }),
pack: {
pack: {
@@ -201,7 +205,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -201,7 +205,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (
STAFF,
STAFF,
)
)
// Mod B: stays installed across the restart.
// Mod B: stays installed across the restart.
- a.registry.install(
+ await a.registry.install(
{
{
manifest: sampleManifest({ id: MOD_B, name: 'Campfire' }),
manifest: sampleManifest({ id: MOD_B, name: 'Campfire' }),
pack: { props: [{ key: 'fire', item: 'campfire', pos: [-20, 0, -20] }] },
pack: { props: [{ key: 'fire', item: 'campfire', pos: [-20, 0, -20] }] },
@@ -215,20 +219,25 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -215,20 +219,25 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(benches.map((e) => e.prop?.defId)).toEqual(['workbench'])
expect(benches.map((e) => e.prop?.defId)).toEqual(['workbench'])
expect(propsOwnedBy(a.world, MOD_B).map((e) => e.prop?.defId)).toEqual(['campfire'])
expect(propsOwnedBy(a.world, MOD_B).map((e) => e.prop?.defId)).toEqual(['campfire'])
expect(ana.inbox.some((m) => m.t === 'announce')).toBe(false)
expect(ana.inbox.some((m) => m.t === 'announce')).toBe(false)
+ await a.registry.flushWrites()
expect(
expect(
- a.registry.auditLog(MOD_A).some((x) => x.action === 'deny' && x.capability === CAP_ANNOUNCE),
+ (await a.registry.auditLog(MOD_A)).some(
+ (x) => x.action === 'deny' && x.capability === CAP_ANNOUNCE,
+ ),
).toBe(true)
).toBe(true)
// Revoke: gone on the very next tick, and players are told it despawned.
// Revoke: gone on the very next tick, and players are told it despawned.
- a.registry.revoke(MOD_A, STAFF, 'test')
+ await a.registry.revoke(MOD_A, STAFF, 'test')
a.game.step()
a.game.step()
expect(propsOwnedBy(a.world, MOD_A)).toEqual([])
expect(propsOwnedBy(a.world, MOD_A)).toEqual([])
expect(a.world.entities.get(benches[0]!.id)).toBeUndefined()
expect(a.world.entities.get(benches[0]!.id)).toBeUndefined()
a.game.onDisconnect(ana.conn)
a.game.onDisconnect(ana.conn)
a.game.shutdown()
a.game.shutdown()
- timeBefore = a.store.meta.get('env_time') ?? ''
- expect(timeBefore).not.toBe('')
+ await a.game.drain()
+ await a.registry.flushWrites()
+ timeBefore = (await a.store.meta.get('env_time')) as number
+ expect(timeBefore).toBeGreaterThan(0)
// Everything committed is published with the games principal.
// Everything committed is published with the games principal.
await a.outbox.flush()
await a.outbox.flush()
@@ -252,9 +261,9 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -252,9 +261,9 @@ describe('Games on platform identity, events and mods (real server, restart)', (
}, 60_000)
}, 60_000)
it('restores the authoritative state after a restart', async () => {
it('restores the authoritative state after a restart', async () => {
- const b = boot(platform)
+ const b = await boot(platform)
// World clock, mod registry and mod props came back from disk.
// World clock, mod registry and mod props came back from disk.
- expect(b.store.meta.get('env_time')).toBe(timeBefore)
+ expect(await b.store.meta.get('env_time')).toBe(timeBefore)
expect(b.registry.get(MOD_A)?.mod.status).toBe('revoked')
expect(b.registry.get(MOD_A)?.mod.status).toBe('revoked')
expect(b.registry.get(MOD_B)?.mod.status).toBe('enabled')
expect(b.registry.get(MOD_B)?.mod.status).toBe('enabled')
expect(propsOwnedBy(b.world, MOD_A)).toEqual([])
expect(propsOwnedBy(b.world, MOD_A)).toEqual([])
@@ -267,35 +276,43 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -267,35 +276,43 @@ describe('Games on platform identity, events and mods (real server, restart)', (
const ana = connect(b.game)
const ana = connect(b.game)
const again = await ana.hello('zyxwvut98', 'tok-ana')
const again = await ana.hello('zyxwvut98', 'tok-ana')
expect(again.welcome).toMatchObject({ playerId: signedInPlayerId })
expect(again.welcome).toMatchObject({ playerId: signedInPlayerId })
- expect(b.store.players.listByToken(SUBJECT).map((p) => p.id)).toEqual([signedInPlayerId])
+ expect((await b.store.players.listByToken(SUBJECT)).map((p) => p.id)).toEqual([
+ signedInPlayerId,
+ ])
b.game.onDisconnect(ana.conn)
b.game.onDisconnect(ana.conn)
b.game.shutdown()
b.game.shutdown()
+ await b.game.drain()
+ await b.registry.flushWrites()
await b.outbox.stop()
await b.outbox.stop()
b.dispose()
b.dispose()
}, 60_000)
}, 60_000)
it('a guest who signs in keeps their character (guest conversion, WS-B task 8)', async () => {
it('a guest who signs in keeps their character (guest conversion, WS-B task 8)', async () => {
- const c = boot(platform)
+ const c = await boot(platform)
const guest = connect(c.game)
const guest = connect(c.game)
const played = await guest.hello('guestconv77')
const played = await guest.hello('guestconv77')
expect(played.welcome).toBeDefined()
expect(played.welcome).toBeDefined()
const guestPlayer = (played.welcome as { playerId: string }).playerId
const guestPlayer = (played.welcome as { playerId: string }).playerId
c.game.shutdown()
c.game.shutdown()
+ await c.game.drain()
+ await c.registry.flushWrites()
await c.outbox.stop()
await c.outbox.stop()
c.dispose()
c.dispose()
- const d = boot(platform)
+ const d = await boot(platform)
const signedIn = connect(d.game)
const signedIn = connect(d.game)
expect((await signedIn.hello('guestconv77', 'tok-ana')).welcome).toBeDefined()
expect((await signedIn.hello('guestconv77', 'tok-ana')).welcome).toBeDefined()
- const mine = d.store.players.listByToken(SUBJECT)
+ const mine = await d.store.players.listByToken(SUBJECT)
expect(mine.some((p) => p.id === guestPlayer)).toBe(true)
expect(mine.some((p) => p.id === guestPlayer)).toBe(true)
- expect(d.store.players.listByToken('guestconv77')).toEqual([])
+ expect(await d.store.players.listByToken('guestconv77')).toEqual([])
d.game.shutdown()
d.game.shutdown()
+ await d.game.drain()
+ await d.registry.flushWrites()
await d.outbox.stop()
await d.outbox.stop()
d.dispose()
d.dispose()
}, 60_000)
}, 60_000)
it('a sign-out everywhere closes the signed-in session, never a guest (WS-B task 4)', async () => {
it('a sign-out everywhere closes the signed-in session, never a guest (WS-B task 4)', async () => {
- const c = boot(platform)
+ const c = await boot(platform)
const ana = connect(c.game)
const ana = connect(c.game)
expect((await ana.hello('abcdefgh12', 'tok-ana')).welcome).toBeDefined()
expect((await ana.hello('abcdefgh12', 'tok-ana')).welcome).toBeDefined()
const guest = connect(c.game)
const guest = connect(c.game)
@@ -305,6 +322,8 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -305,6 +322,8 @@ describe('Games on platform identity, events and mods (real server, restart)', (
expect(ana.inbox.some((m) => m.t === 'reject' && m.reason === 'signed_out')).toBe(true)
expect(ana.inbox.some((m) => m.t === 'reject' && m.reason === 'signed_out')).toBe(true)
expect(guest.inbox.some((m) => m.t === 'reject')).toBe(false)
expect(guest.inbox.some((m) => m.t === 'reject')).toBe(false)
c.game.shutdown()
c.game.shutdown()
+ await c.game.drain()
+ await c.registry.flushWrites()
await c.outbox.stop()
await c.outbox.stop()
c.dispose()
c.dispose()
}, 60_000)
}, 60_000)
@@ -1,16 +1,13 @@
@@ -1,16 +1,13 @@
-import { mkdirSync, existsSync, readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { dirname, join } from 'node:path'
-import {
- createEventsClient,
- createOutbox,
- type Outbox,
- type SqliteDatabase,
-} from 'openvibe-sdk/events'
+import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
+import { createValkey } from 'openvibe-sdk/valkey'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { openPgStore } from '@openvibe/persistence'
import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { loadConfig } from './config.js'
+import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
import { loadHavok } from './havokLoader.js'
import { loadHavok } from './havokLoader.js'
@@ -55,7 +52,7 @@ import { createStaffAuthorizer } from './platform/staffAuth.js'
@@ -55,7 +52,7 @@ import { createStaffAuthorizer } from './platform/staffAuth.js'
/**
/**
* Dedicated authoritative server entry point.
* Dedicated authoritative server entry point.
- * Boot order: config -> content validation (fail fast) -> physics ->
+ * Boot order: config -> content validation (fail fast) -> physics -> database + migrations ->
* persistence -> world restore -> network -> fixed-tick loop.
* persistence -> world restore -> network -> fixed-tick loop.
*/
*/
async function main(): Promise<void> {
async function main(): Promise<void> {
@@ -64,7 +61,7 @@ async function main(): Promise<void> {
@@ -64,7 +61,7 @@ async function main(): Promise<void> {
process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',
process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',
)
)
const config = loadConfig(process.env)
const config = loadConfig(process.env)
- log.info('starting', { port: config.port, db: config.dbPath })
+ log.info('starting', { port: config.port, place: config.placeId })
// Content validates at construction — invalid definitions kill the boot.
// Content validates at construction — invalid definitions kill the boot.
const content = createContent()
const content = createContent()
@@ -102,24 +99,26 @@ async function main(): Promise<void> {
@@ -102,24 +99,26 @@ async function main(): Promise<void> {
const havok = await loadHavok()
const havok = await loadHavok()
const physics = createHeadlessHavokWorld(havok)
const physics = createHeadlessHavokWorld(havok)
- mkdirSync(dirname(config.dbPath), { recursive: true })
- const store = openSqliteStore(config.dbPath)
+ // PostgreSQL (or embedded PGlite in development), migrated before anything writes.
+ const db = await openDb(config.db, log)
+ const store = openPgStore(db, { placeId: config.placeId })
+ await store.ensurePlace()
const metrics = new ServerMetrics()
const metrics = new ServerMetrics()
const world = new GameWorld(content, physics, log.child({ system: 'world' }))
const world = new GameWorld(content, physics, log.child({ system: 'world' }))
- world.seedOrRestore(store)
+ await world.seedOrRestore(store)
// ── Platform integration (roadmap Wave 12) ─────────────────────────
// ── Platform integration (roadmap Wave 12) ─────────────────────────
// Every service call carries the `games` principal's client-credentials
// Every service call carries the `games` principal's client-credentials
// token; with no client secret configured none of this talks to anything.
// token; with no client secret configured none of this talks to anything.
const platform = createPlatformClient(config.platform)
const platform = createPlatformClient(config.platform)
const platformLog = log.child({ system: 'platform' })
const platformLog = log.child({ system: 'platform' })
- let outbox: Outbox | null = null
+ const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
+ let outbox: PgOutbox | null = null
let sink: EventSink = NO_EVENTS
let sink: EventSink = NO_EVENTS
if (platform && config.platform.eventsUrl) {
if (platform && config.platform.eventsUrl) {
let lastError: string | null = null
let lastError: string | null = null
- // better-sqlite3's Transaction<F> is a callable F; the SDK types it as plain F.
- outbox = createOutbox(store.db as unknown as SqliteDatabase, {
+ outbox = createPgOutbox(db, {
events: createEventsClient(platform.client, { source: EVENT_SOURCE }),
events: createEventsClient(platform.client, { source: EVENT_SOURCE }),
intervalMs: 2000,
intervalMs: 2000,
onError: (err) => {
onError: (err) => {
@@ -129,16 +128,19 @@ async function main(): Promise<void> {
@@ -129,16 +128,19 @@ async function main(): Promise<void> {
lastError = message
lastError = message
},
},
})
})
- outbox.ensureSchema()
- sink = outboxSink(outbox)
+ sink = outboxSink(outbox, db)
outbox.start()
outbox.start()
- platformLog.info('events on', { url: config.platform.eventsUrl, pending: outbox.pending() })
+ platformLog.info('events on', {
+ url: config.platform.eventsUrl,
+ pending: await outbox.pending(),
+ })
}
}
const recorder = sink.enabled
const recorder = sink.enabled
? new GameEventRecorder(sink, content.world.id, config.platform.worldSavedEventMinutes * 60_000)
? new GameEventRecorder(sink, content.world.id, config.platform.worldSavedEventMinutes * 60_000)
: undefined
: undefined
- const mods = new ModRegistry(store, content, sink)
- const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {
+ const mods = new ModRegistry(store, content, sink, Date.now, log.child({ system: 'mods' }))
+ await mods.load()
+ const modRuntime = new ModRuntime(mods, content, log.child({ system: 'mods' }), {
reconcileEveryTicks: config.tickRate,
reconcileEveryTicks: config.tickRate,
})
})
// Mod principals live in Network (ADR-013, WS-M task 3): the API asks it first; at boot every install's copy
// Mod principals live in Network (ADR-013, WS-M task 3): the API asks it first; at boot every install's copy
@@ -147,23 +149,21 @@ async function main(): Promise<void> {
@@ -147,23 +149,21 @@ async function main(): Promise<void> {
? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens })
? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens })
: null
: null
if (modGrants) {
if (modGrants) {
- modGrants
- .list()
- .then((principals) => {
- for (const p of principals) mods.applyNetwork(p)
- const known = new Set(principals.map((p) => p.mod_id))
- const unregistered = mods
- .list()
- .filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked')
- .map((v) => v.mod.id)
- if (unregistered.length)
- log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })
+ try {
+ const principals = await modGrants.list()
+ for (const p of principals) await mods.applyNetwork(p)
+ const known = new Set(principals.map((p) => p.mod_id))
+ const unregistered = mods
+ .list()
+ .filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked')
+ .map((v) => v.mod.id)
+ if (unregistered.length)
+ log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })
+ } catch (err) {
+ log.warn('mod principals not read at boot', {
+ error: String((err as Error)?.message ?? err),
})
})
- .catch((err: unknown) =>
- log.warn('mod principals not read at boot', {
- error: String((err as Error)?.message ?? err),
- }),
- )
+ }
}
}
const mirror =
const mirror =
platform && config.platform.mediaUrl
platform && config.platform.mediaUrl
@@ -186,7 +186,7 @@ async function main(): Promise<void> {
@@ -186,7 +186,7 @@ async function main(): Promise<void> {
networkUrl: config.platform.networkUrl,
networkUrl: config.platform.networkUrl,
editorKey: config.editorKey,
editorKey: config.editorKey,
})
})
- const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)
+ const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)
pruneTimer.unref()
pruneTimer.unref()
// games.progress.summary on Network (WS-B task 9): needs the games principal.
// games.progress.summary on Network (WS-B task 9): needs the games principal.
@@ -198,16 +198,22 @@ async function main(): Promise<void> {
@@ -198,16 +198,22 @@ async function main(): Promise<void> {
mods: modRuntime,
mods: modRuntime,
...(progressSummary ? { progressSummary } : {}),
...(progressSummary ? { progressSummary } : {}),
})
})
+ // Async boot: env, markets and the NPC population load before the tick loop starts.
+ await game.load()
const accountData = platform
const accountData = platform
? createAccountData({
? createAccountData({
- db: store.db,
+ db,
identity: store.identity,
identity: store.identity,
send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
closeSessions: async (subjects) => {
closeSessions: async (subjects) => {
let closed = 0
let closed = 0
for (const s of subjects) closed += game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
for (const s of subjects) closed += game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
- if (closed) await new Promise((r) => setTimeout(r, 2000))
+ // Let the closing sockets' character saves land before the rows are erased.
+ if (closed) {
+ await new Promise((r) => setTimeout(r, 2000))
+ await game.drain()
+ }
},
},
log: platformLog.child({ system: 'account-data' }),
log: platformLog.child({ system: 'account-data' }),
})
})
@@ -216,7 +222,7 @@ async function main(): Promise<void> {
@@ -216,7 +222,7 @@ async function main(): Promise<void> {
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
// older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).
// older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).
const revocations = createRevocationEvents({
const revocations = createRevocationEvents({
- db: store.db,
+ db,
secrets: config.platform.eventsSecrets,
secrets: config.platform.eventsSecrets,
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
@@ -225,12 +231,13 @@ async function main(): Promise<void> {
@@ -225,12 +231,13 @@ async function main(): Promise<void> {
// close saves its character) before its characters are erased.
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
// Mod principals (ADR-013): an install's grants follow its principal in Network (a change staff made there).
// Mod principals (ADR-013): an install's grants follow its principal in Network (a change staff made there).
- onModGrants: (p) => {
- const view = mods.applyNetwork(p)
+ onModGrants: async (p) => {
+ const view = await mods.applyNetwork(p)
return view ? `mod:${view.mod.status}` : 'ignored:unknown_mod'
return view ? `mod:${view.mod.status}` : 'ignored:unknown_mod'
},
},
log: platformLog,
log: platformLog,
})
})
+ await revocations.store.load()
if (
if (
platform &&
platform &&
config.platform.eventsUrl &&
config.platform.eventsUrl &&
@@ -260,17 +267,18 @@ async function main(): Promise<void> {
@@ -260,17 +267,18 @@ async function main(): Promise<void> {
// GET /shared/*: this server's own pinned OpenVibe Frame files (D42), not openvibe.network's.
// GET /shared/*: this server's own pinned OpenVibe Frame files (D42), not openvibe.network's.
const serveShared = sharedAssetsHandler()
const serveShared = sharedAssetsHandler()
- // GET /api/ready: world.db answers and the simulation ticks (/healthz stays liveness only).
- const schemaVersion = store.db.prepare('SELECT value FROM meta WHERE key = ?')
+ // GET /api/ready: the database answers (db.ready()) and the simulation ticks (/healthz stays liveness only).
const readiness = createReadiness({
const readiness = createReadiness({
- pingDb: () => schemaVersion.get('schema_version') !== undefined,
+ pingDb: () => db.ready(),
metrics,
metrics,
online: () => game.onlineCount(),
online: () => game.onlineCount(),
})
})
- // Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4).
+ // Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4). With VALKEY_URL the
+ // counters are shared across instances; otherwise they live in this process.
const actorLimits = createGamesActorLimits({
const actorLimits = createGamesActorLimits({
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
+ valkey,
})
})
const http = createHttpServer(
const http = createHttpServer(
config.staticDir,
config.staticDir,
@@ -310,13 +318,12 @@ async function main(): Promise<void> {
@@ -310,13 +318,12 @@ async function main(): Promise<void> {
// Guest conversion (WS-B task 8): this browser's guest character shows up in the account's
// Guest conversion (WS-B task 8): this browser's guest character shows up in the account's
// list (and moves there) the moment it signs in, before any slot is picked.
// list (and moves there) the moment it signs in, before any slot is picked.
if (isGuestToken(token))
if (isGuestToken(token))
- store.identity.adoptGuestCharacter(token, user.subjectId, Date.now())
+ await store.identity.adoptGuestCharacter(token, user.subjectId, Date.now())
} else if (!isGuestToken(token)) {
} else if (!isGuestToken(token)) {
// Never list an account key's characters for a guest query.
// Never list an account key's characters for a guest query.
return []
return []
}
}
- return store.players
- .listByToken(account)
+ return (await store.players.listByToken(account))
.slice(0, 3)
.slice(0, 3)
.map((p) => ({ slot: p.charSlot, name: p.name, appearance: p.appearance }))
.map((p) => ({ slot: p.charSlot, name: p.name, appearance: p.appearance }))
},
},
@@ -328,27 +335,52 @@ async function main(): Promise<void> {
@@ -328,27 +335,52 @@ async function main(): Promise<void> {
if (serveRelease(req, res)) return true
if (serveRelease(req, res)) return true
if (serveShared(req, res)) return true
if (serveShared(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
- // Operational status of the platform adapters; never secrets.
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
- res.end(
- JSON.stringify({
- principal: platform ? platform.clientId : null,
- events: outbox
- ? { enabled: true, pending: outbox.pending(), rejected: outbox.rejected() }
- : { enabled: false },
- media: mirror
- ? {
- enabled: true,
- namespace: config.platform.mediaNamespace,
- ...store.mediaMirrors.counts(),
- }
- : { enabled: false },
- mods: {
- installed: mods.list().length,
- active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
- },
- }),
- )
+ // Operational status of the platform adapters; never secrets. The DB reads are async; the
+ // response is written when they resolve. A database error answers 503 rather than leaving the
+ // request hanging or rejecting unhandled.
+ void (async () => {
+ const mediaCounts = mirror ? await store.mediaMirrors.counts() : null
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
+ res.end(
+ JSON.stringify({
+ principal: platform ? platform.clientId : null,
+ events: outbox
+ ? {
+ enabled: true,
+ pending: await outbox.pending(),
+ rejected: await outbox.rejected(),
+ }
+ : { enabled: false },
+ media: mediaCounts
+ ? {
+ enabled: true,
+ namespace: config.platform.mediaNamespace,
+ ...mediaCounts,
+ }
+ : { enabled: false },
+ mods: {
+ installed: mods.list().length,
+ active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
+ },
+ }),
+ )
+ })().catch((err: unknown) => {
+ log.warn('platform status failed', {
+ error: String((err as Error | undefined)?.message ?? err),
+ })
+ if (res.writableEnded) return
+ res.writeHead(503, {
+ 'content-type': 'application/problem+json; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end(
+ JSON.stringify({
+ title: 'service_unavailable',
+ status: 503,
+ detail: 'platform status unavailable',
+ }),
+ )
+ })
return true
return true
}
}
return handleModsRequest(req, res, {
return handleModsRequest(req, res, {
@@ -413,10 +445,11 @@ async function main(): Promise<void> {
@@ -413,10 +445,11 @@ async function main(): Promise<void> {
// systemd sends SIGTERM (TimeoutStopSec=30). In order: stop taking connections (HTTP and WebSocket
// systemd sends SIGTERM (TimeoutStopSec=30). In order: stop taking connections (HTTP and WebSocket
// upgrades) while requests in flight carry on; stop the simulation, the metrics and prune timers and
// upgrades) while requests in flight carry on; stop the simulation, the metrics and prune timers and
// the media mirror; tell every player, then close every game and editor socket with 1012 (each game
// the media mirror; tell every player, then close every game and editor socket with 1012 (each game
- // close saves that character and records games.player.left; the clients reconnect on their own);
- // save the world; let requests in flight finish (DRAIN_MS); let the mirror pass and the progress
- // summaries settle; stop the event outbox and await its last send (unsent rows stay in the table
- // for the next start); close world.db; exit 0. Everything within DEADLINE_MS, else exit 1.
+ // close saves that character and records games.player.left); a final checkpoint with every session
+ // dirty; await the write-behind drain (the checkpoint and every queued save); let requests in flight
+ // finish (DRAIN_MS); let the mirror pass and the progress summaries settle; stop the event outbox and
+ // await its last send (unsent rows stay for the next start); close the database; exit 0. Everything
+ // within DEADLINE_MS, else exit 1.
let stopping: Promise<void> | null = null
let stopping: Promise<void> | null = null
const shutdown = (signal: string): Promise<void> => {
const shutdown = (signal: string): Promise<void> => {
if (stopping) return stopping
if (stopping) return stopping
@@ -439,13 +472,18 @@ async function main(): Promise<void> {
@@ -439,13 +472,18 @@ async function main(): Promise<void> {
closeSockets(gameWss, SOCKETS_MS),
closeSockets(gameWss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
])
])
+ // Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue
+ // (that checkpoint plus every disconnect save queued before it) drain.
game.shutdown()
game.shutdown()
+ const drained = await game.drain()
const cut = await httpDone
const cut = await httpDone
await within(2000, mirrorDone)
await within(2000, mirrorDone)
await within(1000, progressSummary?.settle())
await within(1000, progressSummary?.settle())
await within(2000, outbox?.stop())
await within(2000, outbox?.stop())
- store.close()
+ await mods.flushWrites()
+ await store.close()
physics.dispose()
physics.dispose()
+ await valkey?.close()
log.info('stopped', {
log.info('stopped', {
ms: Date.now() - t0,
ms: Date.now() - t0,
players: players.closed + players.terminated,
players: players.closed + players.terminated,
@@ -453,6 +491,7 @@ async function main(): Promise<void> {
@@ -453,6 +491,7 @@ async function main(): Promise<void> {
terminated: players.terminated + editorPeers.terminated,
terminated: players.terminated + editorPeers.terminated,
requestsCut: cut,
requestsCut: cut,
outbox: outbox ? 'stopped' : 'off',
outbox: outbox ? 'stopped' : 'off',
+ drained,
})
})
process.exit(0)
process.exit(0)
})().catch((err: unknown) => {
})().catch((err: unknown) => {
@@ -6,9 +6,10 @@
@@ -6,9 +6,10 @@
*/
*/
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createClient } from 'openvibe-sdk/core'
import { createClient } from 'openvibe-sdk/core'
-import { createEventsClient, createOutbox, type SqliteDatabase } from 'openvibe-sdk/events'
+import { sql } from 'openvibe-sdk/db'
+import { createEventsClient, createPgOutbox } from 'openvibe-sdk/events'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { EVENT_SOURCE, outboxSink } from '../platform/gameEvents.js'
import { EVENT_SOURCE, outboxSink } from '../platform/gameEvents.js'
import { CAP_PLACE_PROP } from './contentPack.js'
import { CAP_PLACE_PROP } from './contentPack.js'
@@ -43,8 +44,8 @@ interface Envelope {
@@ -43,8 +44,8 @@ interface Envelope {
}
}
}
}
-function setup() {
- const store = openSqliteStore(':memory:')
+async function setup() {
+ const store = await openTestStore()
const client = createClient({
const client = createClient({
baseUrls: { events: 'http://127.0.0.1:9' },
baseUrls: { events: 'http://127.0.0.1:9' },
autoDiscover: false,
autoDiscover: false,
@@ -53,22 +54,22 @@ function setup() {
@@ -53,22 +54,22 @@ function setup() {
throw new Error('no relay in this test')
throw new Error('no relay in this test')
},
},
})
})
- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
+ const outbox = createPgOutbox(store.db, {
events: createEventsClient(client, { source: EVENT_SOURCE }),
events: createEventsClient(client, { source: EVENT_SOURCE }),
})
})
- outbox.ensureSchema()
- const registry = new ModRegistry(store, createContent(), outboxSink(outbox))
- registry.install(
+ await outbox.ensureSchema()
+ const registry = new ModRegistry(store, createContent(), outboxSink(outbox, store.db))
+ await registry.install(
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
STAFF,
STAFF,
)
)
- const moderation = () =>
+ const moderation = async (): Promise<Envelope[]> =>
(
(
- store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all() as {
- envelope: string
- }[]
+ await store.db.many<{ envelope: Envelope }>(
+ sql`SELECT envelope FROM event_outbox ORDER BY id`,
+ )
)
)
- .map((r) => JSON.parse(r.envelope) as Envelope)
+ .map((r) => r.envelope)
.filter((e) => e.event_type === 'games.moderation.action')
.filter((e) => e.event_type === 'games.moderation.action')
return { registry, outbox, moderation }
return { registry, outbox, moderation }
}
}
@@ -85,10 +86,10 @@ function expectValid(e: Envelope) {
@@ -85,10 +86,10 @@ function expectValid(e: Envelope) {
}
}
describe('games.moderation.action', () => {
describe('games.moderation.action', () => {
- it("staff revoking someone else's mod enqueues exactly one valid event", () => {
- const { registry, outbox, moderation } = setup()
- registry.revoke(MOD_ID, STAFF, 'griefing props')
- const ev = moderation()
+ it("staff revoking someone else's mod enqueues exactly one valid event", async () => {
+ const { registry, outbox, moderation } = await setup()
+ await registry.revoke(MOD_ID, STAFF, 'griefing props')
+ const ev = await moderation()
expect(ev).toHaveLength(1)
expect(ev).toHaveLength(1)
expectValid(ev[0]!)
expectValid(ev[0]!)
expect(ev[0]!.payload.action).toBe('mod.revoked')
expect(ev[0]!.payload.action).toBe('mod.revoked')
@@ -97,29 +98,29 @@ describe('games.moderation.action', () => {
@@ -97,29 +98,29 @@ describe('games.moderation.action', () => {
previous: 'enabled',
previous: 'enabled',
revoked_grants: [CAP_PLACE_PROP],
revoked_grants: [CAP_PLACE_PROP],
})
})
- registry.revoke(MOD_ID, STAFF, 'again')
- expect(moderation()).toHaveLength(1)
+ await registry.revoke(MOD_ID, STAFF, 'again')
+ expect(await moderation()).toHaveLength(1)
void outbox.stop()
void outbox.stop()
})
})
- it('a disable, and the enable that puts it back, are one event each; installing is none', () => {
- const { registry, outbox, moderation } = setup()
- expect(moderation()).toHaveLength(0)
- registry.disable(MOD_ID, STAFF)
- expect(moderation().map((e) => e.payload.action)).toEqual(['mod.disabled'])
- registry.enable(MOD_ID, STAFF)
- const ev = moderation()
+ it('a disable, and the enable that puts it back, are one event each; installing is none', async () => {
+ const { registry, outbox, moderation } = await setup()
+ expect(await moderation()).toHaveLength(0)
+ await registry.disable(MOD_ID, STAFF)
+ expect((await moderation()).map((e) => e.payload.action)).toEqual(['mod.disabled'])
+ await registry.enable(MOD_ID, STAFF)
+ const ev = await moderation()
expect(ev.map((e) => e.payload.action)).toEqual(['mod.disabled', 'mod.enabled'])
expect(ev.map((e) => e.payload.action)).toEqual(['mod.disabled', 'mod.enabled'])
for (const e of ev) expectValid(e)
for (const e of ev) expectValid(e)
void outbox.stop()
void outbox.stop()
})
})
- it('the publisher acting on their own mod enqueues none', () => {
- const { registry, outbox, moderation } = setup()
- registry.disable(MOD_ID, OWNER)
- registry.enable(MOD_ID, OWNER)
- registry.revoke(MOD_ID, OWNER, 'withdrawn by its author')
- expect(moderation()).toHaveLength(0)
+ it('the publisher acting on their own mod enqueues none', async () => {
+ const { registry, outbox, moderation } = await setup()
+ await registry.disable(MOD_ID, OWNER)
+ await registry.enable(MOD_ID, OWNER)
+ await registry.revoke(MOD_ID, OWNER, 'withdrawn by its author')
+ expect(await moderation()).toHaveLength(0)
void outbox.stop()
void outbox.stop()
})
})
})
})
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
import { createServer, type Server } from 'node:http'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { AddressInfo } from 'node:net'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import type { PgPersistenceStore } from '@openvibe/persistence'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
@@ -87,7 +88,8 @@ function fakeNetwork(allow: string[]) {
@@ -87,7 +88,8 @@ function fakeNetwork(allow: string[]) {
}
}
async function start(grants: ModGrantAuthority) {
async function start(grants: ModGrantAuthority) {
- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
+ const registry = new ModRegistry(await openTestStore(), createContent())
+ await registry.load()
server = createServer((req, res) => {
server = createServer((req, res) => {
if (!handleModsRequest(req, res, { registry, authorize: async () => STAFF, grants, log })) {
if (!handleModsRequest(req, res, { registry, authorize: async () => STAFF, grants, log })) {
res.writeHead(404)
res.writeHead(404)
@@ -133,22 +135,22 @@ describe('mod principals in Network', () => {
@@ -133,22 +135,22 @@ describe('mod principals in Network', () => {
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
// Network's events arrive after the API applied its answers: an older revision changes nothing.
// Network's events arrive after the API applied its answers: an older revision changes nothing.
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: MOD_ID,
mod_id: MOD_ID,
status: 'active',
status: 'active',
approved: [CAP_PLACE_PROP],
approved: [CAP_PLACE_PROP],
revision: 1,
revision: 1,
})
})
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: MOD_ID,
mod_id: MOD_ID,
status: 'active',
status: 'active',
approved: [CAP_PLACE_PROP],
approved: [CAP_PLACE_PROP],
revision: 3,
revision: 3,
})
})
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
- expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(false)
+ expect((await registry.auditLog(MOD_ID)).some((a) => a.actor === 'network')).toBe(false)
// A newer one (a staff change in Network) applies.
// A newer one (a staff change in Network) applies.
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: MOD_ID,
mod_id: MOD_ID,
status: 'active',
status: 'active',
approved: [CAP_PLACE_PROP],
approved: [CAP_PLACE_PROP],
@@ -180,30 +182,65 @@ describe('mod principals in Network', () => {
@@ -180,30 +182,65 @@ describe('mod principals in Network', () => {
expect(registry.get(MOD_ID)).toBeNull()
expect(registry.get(MOD_ID)).toBeNull()
})
})
- it("applyNetwork makes an install's copy follow its principal", () => {
- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
- registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)
+ it("applyNetwork makes an install's copy follow its principal", async () => {
+ const registry = new ModRegistry(await openTestStore(), createContent())
+ await registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)
expect(
expect(
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ',
mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ',
status: 'active',
status: 'active',
approved: [],
approved: [],
}),
}),
).toBeNull()
).toBeNull()
- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })
+ await registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: MOD_ID,
mod_id: MOD_ID,
status: 'active',
status: 'active',
approved: [CAP_ANNOUNCE, 'games.not.bindable'],
approved: [CAP_ANNOUNCE, 'games.not.bindable'],
})
})
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
- expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(true)
- registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })
+ expect((await registry.auditLog(MOD_ID)).some((a) => a.actor === 'network')).toBe(true)
+ await registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })
expect(registry.get(MOD_ID)?.mod.status).toBe('revoked')
expect(registry.get(MOD_ID)?.mod.status).toBe('revoked')
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
})
})
+ it('does not advance the revision when its writes fail: the same revision applies on redelivery', async () => {
+ const store = await openTestStore()
+ let fail = true
+ const wrapped = {
+ ...store,
+ mods: {
+ ...store.mods,
+ upsertGrant: async (grant: Parameters<typeof store.mods.upsertGrant>[0]) => {
+ if (fail) throw new Error('db down')
+ await store.mods.upsertGrant(grant)
+ },
+ },
+ } as PgPersistenceStore
+ const registry = new ModRegistry(wrapped, createContent())
+ await registry.install({ manifest, pack, approve: [], enable: true }, STAFF)
+ // The write fails: the revision must not be remembered, or Events' redelivery would be dropped as stale.
+ await expect(
+ registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_ANNOUNCE],
+ revision: 7,
+ }),
+ ).rejects.toThrow('db down')
+ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
+ fail = false
+ await registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_ANNOUNCE],
+ revision: 7,
+ })
+ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
+ })
+
it('the client sends the principal token and maps Network answers', async () => {
it('the client sends the principal token and maps Network answers', async () => {
const seen: { url: string; auth: string | null; body: unknown }[] = []
const seen: { url: string; auth: string | null; body: unknown }[] = []
const answers: Response[] = [
const answers: Response[] = [
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* The mod registry (ADR-013, roadmap Wave 12): installs, the approved subset
* The mod registry (ADR-013, roadmap Wave 12): installs, the approved subset
* of each install's requested capabilities, lifecycle and the audit log, all
* of each install's requested capabilities, lifecycle and the audit log, all
- * in Games' own SQLite database.
+ * in Games' own PostgreSQL database.
*
*
* install validate the manifest + pack, store it, grant the approved subset
* install validate the manifest + pack, store it, grant the approved subset
* enable / disable
* enable / disable
@@ -9,12 +9,14 @@
@@ -9,12 +9,14 @@
* revoke terminal: every grant ends and the runtime retracts the mod's
* revoke terminal: every grant ends and the runtime retracts the mod's
* effects on the next tick
* effects on the next tick
*
*
- * Every change is one transaction holding the registry rows, the audit rows
- * and the `games.mod.*` outbox event. The in-memory view the runtime asks on
- * its hot path (`isGranted`) is refreshed only after that commit. Taking down
- * (revoke, disable) or putting back (enable after a disable) a mod someone
- * else published also writes `games.moderation.action` for Network's
- * moderation audit log (ADR-022) in that transaction.
+ * Every change is one database transaction holding the registry rows, the audit rows and the
+ * `games.mod.*` outbox event. The in-memory view the runtime asks on its hot path (`isGranted`, and the
+ * placements mirror) is refreshed only after that commit.
+ *
+ * The tick never awaits I/O: `audit`, `place` and `unplace` are called from the mod runtime on the tick
+ * and go through a serialized write queue (the placements mirror updates immediately, the database write
+ * commits asynchronously); a failure is logged and counted, never thrown into the tick. Everything the
+ * HTTP API and Network events call is explicit and awaited.
*
*
* Trust tiers are metadata: `isGranted` never looks at them.
* Trust tiers are metadata: `isGranted` never looks at them.
*
*
@@ -29,11 +31,13 @@ import type {
@@ -29,11 +31,13 @@ import type {
ModAuditDto,
ModAuditDto,
ModGrantDto,
ModGrantDto,
ModInstallDto,
ModInstallDto,
+ ModPlacementDto,
ModStatus,
ModStatus,
ModTrustTier,
ModTrustTier,
PersistenceStore,
PersistenceStore,
} from '@openvibe/persistence'
} from '@openvibe/persistence'
import type { SubjectRef } from 'openvibe-sdk/core'
import type { SubjectRef } from 'openvibe-sdk/core'
+import type { Tx } from 'openvibe-sdk/db'
import {
import {
moderationEvent,
moderationEvent,
modEvent,
modEvent,
@@ -96,17 +100,32 @@ export interface ModView {
@@ -96,17 +100,32 @@ export interface ModView {
export class ModRegistry {
export class ModRegistry {
private readonly views = new Map<string, ModView>()
private readonly views = new Map<string, ModView>()
+ /** In-memory mirror of each install's placements the runtime reads on the tick. */
+ private readonly placementsByMod = new Map<string, ModPlacementDto[]>()
/** The Network principal revision each install's grants were last set from (an older event changes nothing). */
/** The Network principal revision each install's grants were last set from (an older event changes nothing). */
private readonly networkRevision = new Map<string, number>()
private readonly networkRevision = new Map<string, number>()
+ /** Serialized writes the tick path (and only it) fires without awaiting. */
+ private writeChain: Promise<void> = Promise.resolve()
private revision = 0
private revision = 0
+ /** Writes the tick path queued that failed; the runtime keeps running. */
+ writeFailures = 0
constructor(
constructor(
private readonly store: PersistenceStore,
private readonly store: PersistenceStore,
private readonly content: ContentRegistry,
private readonly content: ContentRegistry,
private readonly events: EventSink = NO_EVENTS,
private readonly events: EventSink = NO_EVENTS,
private readonly now: () => number = Date.now,
private readonly now: () => number = Date.now,
- ) {
- for (const mod of store.mods.list()) this.refresh(mod.id)
+ private readonly log: { warn(msg: string, meta?: unknown): void } = {
+ warn: (msg, meta) => console.warn(msg, meta),
+ },
+ ) {}
+
+ /** Async boot: read every install, its grants and its placements into the in-memory mirrors. */
+ async load(): Promise<void> {
+ for (const mod of await this.store.mods.list()) await this.refresh(mod.id)
+ for (const view of this.views.values()) {
+ this.placementsByMod.set(view.mod.id, await this.store.mods.placements(view.mod.id))
+ }
}
}
/** Bumps on every committed change; the runtime reconciles when it moves. */
/** Bumps on every committed change; the runtime reconciles when it moves. */
@@ -126,6 +145,11 @@ export class ModRegistry {
@@ -126,6 +145,11 @@ export class ModRegistry {
return this.views.get(id)?.mod.status === 'enabled'
return this.views.get(id)?.mod.status === 'enabled'
}
}
+ /** The in-memory placements mirror (the tick reads this, never the database). */
+ placements(modId: string): ModPlacementDto[] {
+ return this.placementsByMod.get(modId) ?? []
+ }
+
/**
/**
* The one grant check. True only while the install is enabled and the
* The one grant check. True only while the install is enabled and the
* capability is in its approved, unrevoked subset. The trust tier is not
* capability is in its approved, unrevoked subset. The trust tier is not
@@ -136,30 +160,116 @@ export class ModRegistry {
@@ -136,30 +160,116 @@ export class ModRegistry {
return view !== undefined && view.mod.status === 'enabled' && view.granted.has(capability)
return view !== undefined && view.mod.status === 'enabled' && view.granted.has(capability)
}
}
- auditLog(id: string, limit = 100): ModAuditDto[] {
+ auditLog(id: string, limit = 100): Promise<ModAuditDto[]> {
return this.store.mods.auditLog(id, Math.min(Math.max(limit, 1), 500))
return this.store.mods.auditLog(id, Math.min(Math.max(limit, 1), 500))
}
}
- /** Appends a runtime audit record (use, deny, retract, …). */
- audit(id: string, action: string, capability: string | null, detail?: Record<string, unknown>) {
- this.store.mods.audit({
- modId: id,
- action,
- capability,
- actor: SYSTEM_ACTOR.audit,
- detail: detail ?? null,
- at: this.now(),
+ /**
+ * Appends a runtime audit record (use, deny, retract, …). Called from the tick: the write is queued,
+ * not awaited, and the placements/audit queue is one at a time so ordering is kept.
+ */
+ audit(
+ id: string,
+ action: string,
+ capability: string | null,
+ detail?: Record<string, unknown>,
+ ): void {
+ this.enqueueWrite(async () => {
+ await this.store.mods.audit({
+ modId: id,
+ action,
+ capability,
+ actor: SYSTEM_ACTOR.audit,
+ detail: detail ?? null,
+ at: this.now(),
+ })
+ })
+ }
+
+ /** Records (or re-records) a placement in the mirror and queue-writes it. Called from the tick. */
+ place(modId: string, key: string, entityId: string | null): void {
+ const at = this.now()
+ const before = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key === key)
+ const list = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key !== key)
+ list.push({ modId, key, entityId, at })
+ this.placementsByMod.set(modId, list)
+ this.enqueueWrite(
+ async () => {
+ await this.store.mods.setPlacement({ modId, key, entityId, at })
+ },
+ () => this.rollbackPlacement(modId, key, at, before),
+ )
+ }
+
+ /** Removes a placement from the mirror and queue-writes the delete. Called from the tick. */
+ unplace(modId: string, key: string): void {
+ const before = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key === key)
+ const list = (this.placementsByMod.get(modId) ?? []).filter((p) => p.key !== key)
+ this.placementsByMod.set(modId, list)
+ this.enqueueWrite(
+ async () => {
+ await this.store.mods.deletePlacement(modId, key)
+ },
+ () => this.rollbackPlacement(modId, key, null, before),
+ )
+ }
+
+ /**
+ * The queued write never landed: put the mirror back so it agrees with the database. Only undoes the
+ * change when the mirror still holds it (`stamp` is what this call set, or null for a removal), so a
+ * later place/unplace of the same key is left to its own write.
+ */
+ private rollbackPlacement(
+ modId: string,
+ key: string,
+ stamp: number | null,
+ before: ModPlacementDto[],
+ ): void {
+ const current = this.placementsByMod.get(modId) ?? []
+ const entry = current.find((p) => p.key === key)
+ if ((entry?.at ?? null) !== stamp) return
+ const rest = current.filter((p) => p.key !== key)
+ this.placementsByMod.set(modId, [...rest, ...before])
+ this.log.warn('mod placement write failed: mirror rolled back', { mod: modId, key })
+ }
+
+ /** One serialized write queue for the tick path; failures are retried, then logged and counted, never thrown. */
+ private enqueueWrite(write: () => Promise<void>, onGiveUp?: () => void): void {
+ this.writeChain = this.writeChain.then(async () => {
+ const delaysMs = [25, 100]
+ for (let attempt = 0; ; attempt++) {
+ try {
+ await write()
+ return
+ } catch (err: unknown) {
+ if (attempt < delaysMs.length) {
+ await new Promise((r) => setTimeout(r, delaysMs[attempt]))
+ continue
+ }
+ this.writeFailures++
+ onGiveUp?.()
+ this.log.warn('mod write failed', {
+ error: String((err as Error | undefined)?.message ?? err),
+ })
+ return
+ }
+ }
})
})
}
}
+ /** Waits for queued tick-path writes (shutdown/tests). */
+ async flushWrites(): Promise<void> {
+ await this.writeChain
+ }
+
/** Everything install checks before it writes: the manifest, the pack, the approved subset and the options. */
/** Everything install checks before it writes: the manifest, the pack, the approved subset and the options. */
- checkInstall(req: InstallRequest): {
+ async checkInstall(req: InstallRequest): Promise<{
manifest: ModManifest
manifest: ModManifest
pack: ContentPack
pack: ContentPack
approve: string[]
approve: string[]
trustTier: ModTrustTier
trustTier: ModTrustTier
enable: boolean
enable: boolean
- } {
+ }> {
const manifestCheck = validateManifest(req.manifest)
const manifestCheck = validateManifest(req.manifest)
if (!manifestCheck.ok) {
if (!manifestCheck.ok) {
throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
@@ -188,14 +298,14 @@ export class ModRegistry {
@@ -188,14 +298,14 @@ export class ModRegistry {
for (const cap of approve) this.assertGrantable(manifest, cap)
for (const cap of approve) this.assertGrantable(manifest, cap)
const trustTier = parseTrustTier(req.trustTier ?? 'unreviewed')
const trustTier = parseTrustTier(req.trustTier ?? 'unreviewed')
const enable = req.enable === true
const enable = req.enable === true
- if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {
+ if (this.views.has(manifest.id) || (await this.store.mods.get(manifest.id))) {
throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
}
}
return { manifest, pack, approve, trustTier, enable }
return { manifest, pack, approve, trustTier, enable }
}
}
- install(req: InstallRequest, actor: ModActor): ModView {
- const { manifest, pack, approve, trustTier, enable } = this.checkInstall(req)
+ async install(req: InstallRequest, actor: ModActor): Promise<ModView> {
+ const { manifest, pack, approve, trustTier, enable } = await this.checkInstall(req)
const requested = new Set(manifest.permissions.capabilities)
const requested = new Set(manifest.permissions.capabilities)
const at = this.now()
const at = this.now()
const mod: ModInstallDto = {
const mod: ModInstallDto = {
@@ -212,20 +322,20 @@ export class ModRegistry {
@@ -212,20 +322,20 @@ export class ModRegistry {
installedAt: at,
installedAt: at,
updatedAt: at,
updatedAt: at,
}
}
- this.store.transaction(() => {
- this.store.mods.insert(mod)
- this.writeAudit(mod.id, 'install', null, actor, {
+ await this.store.transaction(async (t) => {
+ await this.store.mods.insert(mod)
+ await this.writeAudit(mod.id, 'install', null, actor, {
version: mod.version,
version: mod.version,
runtime: mod.runtime,
runtime: mod.runtime,
trust_tier: trustTier,
trust_tier: trustTier,
requested: [...requested].sort(),
requested: [...requested].sort(),
})
})
for (const cap of approve) {
for (const cap of approve) {
- this.store.mods.upsertGrant(grantRow(mod.id, cap, actor, at))
- this.writeAudit(mod.id, 'grant', cap, actor, null)
+ await this.store.mods.upsertGrant(grantRow(mod.id, cap, actor, at))
+ await this.writeAudit(mod.id, 'grant', cap, actor, null)
}
}
- if (enable) this.writeAudit(mod.id, 'enable', null, actor, null)
- this.emit('installed', mod, actor, {
+ if (enable) await this.writeAudit(mod.id, 'enable', null, actor, null)
+ await this.emit(t, 'installed', mod, actor, {
requested: [...requested].sort(),
requested: [...requested].sort(),
granted: [...approve].sort(),
granted: [...approve].sort(),
status: mod.status,
status: mod.status,
@@ -234,31 +344,31 @@ export class ModRegistry {
@@ -234,31 +344,31 @@ export class ModRegistry {
return this.refresh(mod.id)
return this.refresh(mod.id)
}
}
- enable(id: string, actor: ModActor): ModView {
+ enable(id: string, actor: ModActor): Promise<ModView> {
return this.setStatus(id, 'enabled', actor)
return this.setStatus(id, 'enabled', actor)
}
}
- disable(id: string, actor: ModActor): ModView {
+ disable(id: string, actor: ModActor): Promise<ModView> {
return this.setStatus(id, 'disabled', actor)
return this.setStatus(id, 'disabled', actor)
}
}
/** Ends the install for good: every grant is revoked and the mod stops at the next tick. */
/** Ends the install for good: every grant is revoked and the mod stops at the next tick. */
- revoke(id: string, actor: ModActor, reason?: string): ModView {
+ async revoke(id: string, actor: ModActor, reason?: string): Promise<ModView> {
const view = this.require(id)
const view = this.require(id)
if (view.mod.status === 'revoked') return view
if (view.mod.status === 'revoked') return view
const at = this.now()
const at = this.now()
- this.store.transaction(() => {
+ await this.store.transaction(async (t) => {
for (const cap of view.granted) {
for (const cap of view.granted) {
- this.store.mods.revokeGrant(id, cap, actor.audit, at)
- this.writeAudit(id, 'revoke_grant', cap, actor, null)
+ await this.store.mods.revokeGrant(id, cap, actor.audit, at)
+ await this.writeAudit(id, 'revoke_grant', cap, actor, null)
}
}
- this.store.mods.setStatus(id, 'revoked', at)
- this.writeAudit(id, 'revoke', null, actor, reason ? { reason } : null)
- this.emit('revoked', { ...view.mod, status: 'revoked' }, actor, {
+ await this.store.mods.setStatus(id, 'revoked', at)
+ await this.writeAudit(id, 'revoke', null, actor, reason ? { reason } : null)
+ await this.emit(t, 'revoked', { ...view.mod, status: 'revoked' }, actor, {
revoked_grants: [...view.granted].sort(),
revoked_grants: [...view.granted].sort(),
...(reason ? { reason } : {}),
...(reason ? { reason } : {}),
})
})
- this.moderated('mod.revoked', view, actor, reason, {
+ await this.moderated(t, 'mod.revoked', view, actor, reason, {
previous: view.mod.status,
previous: view.mod.status,
revoked_grants: [...view.granted].sort(),
revoked_grants: [...view.granted].sort(),
})
})
@@ -266,7 +376,7 @@ export class ModRegistry {
@@ -266,7 +376,7 @@ export class ModRegistry {
return this.refresh(id)
return this.refresh(id)
}
}
- grant(id: string, capability: string, actor: ModActor): ModView {
+ async grant(id: string, capability: string, actor: ModActor): Promise<ModView> {
const view = this.require(id)
const view = this.require(id)
if (view.mod.status === 'revoked') {
if (view.mod.status === 'revoked') {
throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
@@ -274,23 +384,23 @@ export class ModRegistry {
@@ -274,23 +384,23 @@ export class ModRegistry {
this.assertGrantable(view.manifest, capability)
this.assertGrantable(view.manifest, capability)
if (view.granted.has(capability)) return view
if (view.granted.has(capability)) return view
const at = this.now()
const at = this.now()
- this.store.transaction(() => {
- this.store.mods.upsertGrant(grantRow(id, capability, actor, at))
- this.writeAudit(id, 'grant', capability, actor, null)
- this.emit('grants_changed', view.mod, actor, { granted: [capability], revoked: [] })
+ await this.store.transaction(async (t) => {
+ await this.store.mods.upsertGrant(grantRow(id, capability, actor, at))
+ await this.writeAudit(id, 'grant', capability, actor, null)
+ await this.emit(t, 'grants_changed', view.mod, actor, { granted: [capability], revoked: [] })
})
})
return this.refresh(id)
return this.refresh(id)
}
}
- revokeGrant(id: string, capability: string, actor: ModActor): ModView {
+ async revokeGrant(id: string, capability: string, actor: ModActor): Promise<ModView> {
const view = this.require(id)
const view = this.require(id)
const at = this.now()
const at = this.now()
let changed = false
let changed = false
- this.store.transaction(() => {
- changed = this.store.mods.revokeGrant(id, capability, actor.audit, at)
+ await this.store.transaction(async (t) => {
+ changed = await this.store.mods.revokeGrant(id, capability, actor.audit, at)
if (!changed) return
if (!changed) return
- this.writeAudit(id, 'revoke_grant', capability, actor, null)
- this.emit('grants_changed', view.mod, actor, { granted: [], revoked: [capability] })
+ await this.writeAudit(id, 'revoke_grant', capability, actor, null)
+ await this.emit(t, 'grants_changed', view.mod, actor, { granted: [], revoked: [capability] })
})
})
return changed ? this.refresh(id) : view
return changed ? this.refresh(id) : view
}
}
@@ -301,7 +411,7 @@ export class ModRegistry {
@@ -301,7 +411,7 @@ export class ModRegistry {
}
}
/** What grant checks before it writes (the Network call comes between). */
/** What grant checks before it writes (the Network call comes between). */
- checkGrant(id: string, capability: string): void {
+ async checkGrant(id: string, capability: string): Promise<void> {
const view = this.require(id)
const view = this.require(id)
if (view.mod.status === 'revoked') {
if (view.mod.status === 'revoked') {
throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
@@ -314,24 +424,32 @@ export class ModRegistry {
@@ -314,24 +424,32 @@ export class ModRegistry {
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
*/
*/
- applyNetwork(
+ async applyNetwork(
principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number },
principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number },
actor: ModActor = NETWORK_ACTOR,
actor: ModActor = NETWORK_ACTOR,
- ): ModView | null {
+ ): Promise<ModView | null> {
const view = this.views.get(principal.mod_id)
const view = this.views.get(principal.mod_id)
if (!view) return null
if (!view) return null
- // Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.
- if (principal.revision !== undefined) {
- if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return view
- this.networkRevision.set(view.mod.id, principal.revision)
+ // Events can arrive after the API already applied a newer answer: an older (or equal) revision changes
+ // nothing. The revision is remembered only once the writes below succeed, so a failed write leaves the
+ // answer eligible for redelivery instead of being dropped as stale.
+ if (
+ principal.revision !== undefined &&
+ principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)
+ )
+ return view
+ const revision = principal.revision
+ const applied = (outcome: ModView): ModView => {
+ if (revision !== undefined) this.noteNetworkRevision(view.mod.id, revision)
+ return outcome
}
}
if (principal.status === 'revoked')
if (principal.status === 'revoked')
- return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')
- if (view.mod.status === 'revoked') return view
+ return applied(await this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network'))
+ if (view.mod.status === 'revoked') return applied(view)
const approved = new Set(principal.approved)
const approved = new Set(principal.approved)
let current = view
let current = view
for (const cap of [...current.granted])
for (const cap of [...current.granted])
- if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)
+ if (!approved.has(cap)) current = await this.revokeGrant(view.mod.id, cap, actor)
for (const cap of approved) {
for (const cap of approved) {
if (current.granted.has(cap)) continue
if (current.granted.has(cap)) continue
try {
try {
@@ -339,29 +457,33 @@ export class ModRegistry {
@@ -339,29 +457,33 @@ export class ModRegistry {
} catch {
} catch {
continue
continue
}
}
- current = this.grant(view.mod.id, cap, actor)
+ current = await this.grant(view.mod.id, cap, actor)
}
}
- return current
+ return applied(current)
}
}
- private setStatus(id: string, status: ModStatus, actor: ModActor): ModView {
+ private async setStatus(id: string, status: ModStatus, actor: ModActor): Promise<ModView> {
const view = this.require(id)
const view = this.require(id)
if (view.mod.status === 'revoked') {
if (view.mod.status === 'revoked') {
throw new ModError('mod.revoked', 'a revoked install stays revoked; install a new one', 409)
throw new ModError('mod.revoked', 'a revoked install stays revoked; install a new one', 409)
}
}
if (view.mod.status === status) return view
if (view.mod.status === status) return view
const at = this.now()
const at = this.now()
- this.store.transaction(() => {
+ await this.store.transaction(async (t) => {
// Enabling puts a mod back only when someone disabled it before (a first enable is not moderation).
// Enabling puts a mod back only when someone disabled it before (a first enable is not moderation).
const restoring =
const restoring =
status === 'enabled' &&
status === 'enabled' &&
- this.store.mods.auditLog(id, 10_000).some((a: ModAuditDto) => a.action === 'disable')
- this.store.mods.setStatus(id, status, at)
+ (await this.store.mods.auditLog(id, 10_000)).some(
+ (a: ModAuditDto) => a.action === 'disable',
+ )
+ await this.store.mods.setStatus(id, status, at)
const action = status === 'enabled' ? 'enable' : 'disable'
const action = status === 'enabled' ? 'enable' : 'disable'
- this.writeAudit(id, action, null, actor, null)
- this.emit(status === 'enabled' ? 'enabled' : 'disabled', view.mod, actor, {})
+ await this.writeAudit(id, action, null, actor, null)
+ await this.emit(t, status === 'enabled' ? 'enabled' : 'disabled', view.mod, actor, {})
if (status === 'disabled' || restoring) {
if (status === 'disabled' || restoring) {
- this.moderated(`mod.${status}`, view, actor, undefined, { previous: view.mod.status })
+ await this.moderated(t, `mod.${status}`, view, actor, undefined, {
+ previous: view.mod.status,
+ })
}
}
})
})
return this.refresh(id)
return this.refresh(id)
@@ -386,14 +508,14 @@ export class ModRegistry {
@@ -386,14 +508,14 @@ export class ModRegistry {
return view
return view
}
}
- private writeAudit(
+ private async writeAudit(
id: string,
id: string,
action: string,
action: string,
capability: string | null,
capability: string | null,
actor: ModActor,
actor: ModActor,
detail: Record<string, unknown> | null,
detail: Record<string, unknown> | null,
- ): void {
- this.store.mods.audit({
+ ): Promise<void> {
+ await this.store.mods.audit({
modId: id,
modId: id,
action,
action,
capability,
capability,
@@ -404,17 +526,19 @@ export class ModRegistry {
@@ -404,17 +526,19 @@ export class ModRegistry {
}
}
/** games.moderation.action, unless the publisher is acting on their own mod. */
/** games.moderation.action, unless the publisher is acting on their own mod. */
- private moderated(
+ private async moderated(
+ t: Tx,
action: string,
action: string,
view: ModView,
view: ModView,
actor: ModActor,
actor: ModActor,
reason: string | undefined,
reason: string | undefined,
details: Record<string, unknown>,
details: Record<string, unknown>,
- ): void {
+ ): Promise<void> {
const publisher = view.manifest.publisher
const publisher = view.manifest.publisher
const owner = publisher && publisher.type === 'user' ? publisher.id : null
const owner = publisher && publisher.type === 'user' ? publisher.id : null
if (owner && actor.subject.type === 'user' && actor.subject.id === owner) return
if (owner && actor.subject.type === 'user' && actor.subject.id === owner) return
- this.events.enqueue(
+ await this.events.enqueue(
+ t,
moderationEvent(
moderationEvent(
action,
action,
{ type: 'mod', id: view.mod.id, ownerSubject: owner },
{ type: 'mod', id: view.mod.id, ownerSubject: owner },
@@ -427,25 +551,25 @@ export class ModRegistry {
@@ -427,25 +551,25 @@ export class ModRegistry {
)
)
}
}
- private emit(
+ private async emit(
+ t: Tx,
kind: ModLifecycle,
kind: ModLifecycle,
mod: ModInstallDto,
mod: ModInstallDto,
actor: ModActor,
actor: ModActor,
detail: Record<string, unknown>,
detail: Record<string, unknown>,
- ): void {
- this.events.enqueue(modEvent(kind, mod, actor.subject, detail))
+ ): Promise<void> {
+ await this.events.enqueue(t, modEvent(kind, mod, actor.subject, detail))
}
}
/** Re-reads one install after a committed change. */
/** Re-reads one install after a committed change. */
- private refresh(id: string): ModView {
- const mod = this.store.mods.get(id)
+ private async refresh(id: string): Promise<ModView> {
+ const mod = await this.store.mods.get(id)
if (!mod) {
if (!mod) {
this.views.delete(id)
this.views.delete(id)
throw new ModError('mod.not_found', `no mod ${id}`, 404)
throw new ModError('mod.not_found', `no mod ${id}`, 404)
}
}
const granted = new Set(
const granted = new Set(
- this.store.mods
- .grants(id)
+ (await this.store.mods.grants(id))
.filter((g: ModGrantDto) => g.revokedAt === null)
.filter((g: ModGrantDto) => g.revokedAt === null)
.map((g) => g.capability),
.map((g) => g.capability),
)
)
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { createServer, type IncomingMessage, type Server } from 'node:http'
import { createServer, type IncomingMessage, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { AddressInfo } from 'node:net'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { verifyUserToken } from 'openvibe-sdk/auth'
import { verifyUserToken } from 'openvibe-sdk/auth'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { createMockPlatform } from 'openvibe-sdk/testing'
@@ -25,7 +25,8 @@ afterEach(() => {
@@ -25,7 +25,8 @@ afterEach(() => {
})
})
async function start(authorize: (req: IncomingMessage) => Promise<ModActor | null>) {
async function start(authorize: (req: IncomingMessage) => Promise<ModActor | null>) {
- const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
+ const registry = new ModRegistry(await openTestStore(), createContent())
+ await registry.load()
server = createServer((req, res) => {
server = createServer((req, res) => {
if (!handleModsRequest(req, res, { registry, authorize, log })) {
if (!handleModsRequest(req, res, { registry, authorize, log })) {
res.writeHead(404)
res.writeHead(404)
@@ -167,7 +167,9 @@ export function handleModsRequest(
@@ -167,7 +167,9 @@ export function handleModsRequest(
if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
const limit = Number(url.searchParams.get('limit') ?? 100)
const limit = Number(url.searchParams.get('limit') ?? 100)
if (!api.registry.get(id)) return problem(res, 404, 'mod.not_found', 'no such mod')
if (!api.registry.get(id)) return problem(res, 404, 'mod.not_found', 'no such mod')
- json(res, 200, { audit: api.registry.auditLog(id, Number.isFinite(limit) ? limit : 100) })
+ json(res, 200, {
+ audit: await api.registry.auditLog(id, Number.isFinite(limit) ? limit : 100),
+ })
return
return
}
}
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
@@ -177,7 +179,7 @@ export function handleModsRequest(
@@ -177,7 +179,7 @@ export function handleModsRequest(
const principal = await api.grants.change(id, cap, 'revoke', actor.audit)
const principal = await api.grants.change(id, cap, 'revoke', actor.audit)
api.registry.noteNetworkRevision(id, principal.revision)
api.registry.noteNetworkRevision(id, principal.revision)
}
}
- json(res, 200, publicView(api.registry.revokeGrant(id, cap, actor)))
+ json(res, 200, publicView(await api.registry.revokeGrant(id, cap, actor)))
return
return
}
}
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
@@ -195,12 +197,12 @@ export function handleModsRequest(
@@ -195,12 +197,12 @@ export function handleModsRequest(
let revision = 0
let revision = 0
if (api.grants) {
if (api.grants) {
// Network registers mod:<id> first; what it approved is what is granted here.
// Network registers mod:<id> first; what it approved is what is granted here.
- const checked = api.registry.checkInstall(request)
+ const checked = await api.registry.checkInstall(request)
const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)
const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)
request.approve = principal.status === 'active' ? principal.approved : []
request.approve = principal.status === 'active' ? principal.approved : []
revision = principal.revision
revision = principal.revision
}
}
- const view = api.registry.install(request, actor)
+ const view = await api.registry.install(request, actor)
if (revision) api.registry.noteNetworkRevision(view.mod.id, revision)
if (revision) api.registry.noteNetworkRevision(view.mod.id, revision)
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
json(res, 201, publicView(view))
json(res, 201, publicView(view))
@@ -208,7 +210,9 @@ export function handleModsRequest(
@@ -208,7 +210,9 @@ export function handleModsRequest(
}
}
if (parts.length === 2 && (parts[1] === 'enable' || parts[1] === 'disable')) {
if (parts.length === 2 && (parts[1] === 'enable' || parts[1] === 'disable')) {
const view =
const view =
- parts[1] === 'enable' ? api.registry.enable(id, actor) : api.registry.disable(id, actor)
+ parts[1] === 'enable'
+ ? await api.registry.enable(id, actor)
+ : await api.registry.disable(id, actor)
api.log.info(`mod ${parts[1]}d`, { mod: id, by: actor.audit })
api.log.info(`mod ${parts[1]}d`, { mod: id, by: actor.audit })
json(res, 200, publicView(view))
json(res, 200, publicView(view))
return
return
@@ -221,7 +225,7 @@ export function handleModsRequest(
@@ -221,7 +225,7 @@ export function handleModsRequest(
(await api.grants.revoke(id, actor.audit, reason)).revision,
(await api.grants.revoke(id, actor.audit, reason)).revision,
)
)
}
}
- const view = api.registry.revoke(id, actor, reason)
+ const view = await api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
api.log.info('mod revoked', { mod: id, by: actor.audit })
json(res, 200, publicView(view))
json(res, 200, publicView(view))
return
return
@@ -231,11 +235,11 @@ export function handleModsRequest(
@@ -231,11 +235,11 @@ export function handleModsRequest(
return problem(res, 400, 'mod.invalid_request', 'capability is required')
return problem(res, 400, 'mod.invalid_request', 'capability is required')
}
}
if (api.grants) {
if (api.grants) {
- api.registry.checkGrant(id, b.capability)
+ await api.registry.checkGrant(id, b.capability)
const principal = await api.grants.change(id, b.capability, 'approve', actor.audit)
const principal = await api.grants.change(id, b.capability, 'approve', actor.audit)
api.registry.noteNetworkRevision(id, principal.revision)
api.registry.noteNetworkRevision(id, principal.revision)
}
}
- json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
+ json(res, 200, publicView(await api.registry.grant(id, b.capability, actor)))
return
return
}
}
problem(res, 404, 'request.not_found', 'no such mods route')
problem(res, 404, 'request.not_found', 'no such mods route')
@@ -1,8 +1,6 @@
@@ -1,8 +1,6 @@
-import { mkdtempSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
+import type { PgPersistenceStore } from '@openvibe/persistence'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { EventSink } from '../platform/gameEvents.js'
import type { EventSink } from '../platform/gameEvents.js'
@@ -46,25 +44,53 @@ function fakeHost() {
@@ -46,25 +44,53 @@ function fakeHost() {
function captureSink(): EventSink & { types: string[] } {
function captureSink(): EventSink & { types: string[] } {
const types: string[] = []
const types: string[] = []
- return { enabled: true, types, enqueue: (e) => types.push(e.event_type) }
+ return {
+ enabled: true,
+ types,
+ enqueue: async (_t, e) => {
+ types.push(e.event_type)
+ },
+ }
+}
+
+/** A store whose mods.setStatus rejects while `fail()` is true (the disable write never lands). */
+function failingDisableStore(store: PgPersistenceStore, fail: () => boolean): PgPersistenceStore {
+ const wrapped = Object.create(store) as PgPersistenceStore
+ Object.defineProperty(wrapped, 'mods', {
+ value: new Proxy(store.mods, {
+ get(target, prop, receiver) {
+ if (prop === 'setStatus') {
+ return async (...args: unknown[]): Promise<void> => {
+ if (fail()) throw new Error('db down')
+ return (target.setStatus as (...a: unknown[]) => Promise<void>)(...args)
+ }
+ }
+ return Reflect.get(target, prop, receiver)
+ },
+ }),
+ })
+ return wrapped
}
}
-function setup(store: SqlitePersistenceStore = openSqliteStore(':memory:')) {
+const settle = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
+
+async function setup(store?: PgPersistenceStore) {
+ const db = store ?? (await openTestStore())
let now = 1_000_000
let now = 1_000_000
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
const sink = captureSink()
const sink = captureSink()
- const registry = new ModRegistry(store, content, sink, clock.now)
- const runtime = new ModRuntime(registry, store, content, log, {
+ const registry = new ModRegistry(db, content, sink, clock.now)
+ const runtime = new ModRuntime(registry, content, log, {
reconcileEveryTicks: 30,
reconcileEveryTicks: 30,
now: clock.now,
now: clock.now,
})
})
- return { store, registry, runtime, sink, clock }
+ return { store: db, registry, runtime, sink, clock }
}
}
describe('mod registry', () => {
describe('mod registry', () => {
- it('installs with the approved subset only and audits install + grants', () => {
- const { registry, sink } = setup()
- const view = registry.install(
+ it('installs with the approved subset only and audits install + grants', async () => {
+ const { registry, sink } = await setup()
+ const view = await registry.install(
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
STAFF,
STAFF,
)
)
@@ -72,10 +98,7 @@ describe('mod registry', () => {
@@ -72,10 +98,7 @@ describe('mod registry', () => {
expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(
expect(
- registry
- .auditLog(MOD_ID)
- .map((a) => [a.action, a.capability])
- .reverse(),
+ (await registry.auditLog(MOD_ID)).map((a) => [a.action, a.capability]).reverse(),
).toEqual([
).toEqual([
['install', null],
['install', null],
['grant', CAP_PLACE_PROP],
['grant', CAP_PLACE_PROP],
@@ -84,26 +107,26 @@ describe('mod registry', () => {
@@ -84,26 +107,26 @@ describe('mod registry', () => {
expect(sink.types).toEqual(['games.mod.installed'])
expect(sink.types).toEqual(['games.mod.installed'])
})
})
- it('refuses grants that were not requested or have no binding', () => {
- const { registry } = setup()
+ it('refuses grants that were not requested or have no binding', async () => {
+ const { registry } = await setup()
const manifest = sampleManifest({
const manifest = sampleManifest({
permissions: { capabilities: [CAP_ANNOUNCE, 'media.object.read'] },
permissions: { capabilities: [CAP_ANNOUNCE, 'media.object.read'] },
})
})
- expect(() =>
+ await expect(
registry.install({ manifest, pack: {}, approve: [CAP_PLACE_PROP] }, STAFF),
registry.install({ manifest, pack: {}, approve: [CAP_PLACE_PROP] }, STAFF),
- ).toThrow(ModError)
- expect(() =>
+ ).rejects.toThrow(ModError)
+ await expect(
registry.install({ manifest, pack: {}, approve: ['media.object.read'] }, STAFF),
registry.install({ manifest, pack: {}, approve: ['media.object.read'] }, STAFF),
- ).toThrow(/no binding/)
+ ).rejects.toThrow(/no binding/)
// A pack section needs its capability to at least be requested.
// A pack section needs its capability to at least be requested.
- expect(() =>
+ await expect(
registry.install({ manifest, pack: { props: PACK.props }, approve: [] }, STAFF),
registry.install({ manifest, pack: { props: PACK.props }, approve: [] }, STAFF),
- ).toThrow(/does not request/)
+ ).rejects.toThrow(/does not request/)
})
})
- it('never lets a trust tier stand in for a grant', () => {
- const { registry } = setup()
- registry.install(
+ it('never lets a trust tier stand in for a grant', async () => {
+ const { registry } = await setup()
+ await registry.install(
{
{
manifest: sampleManifest(),
manifest: sampleManifest(),
pack: PACK,
pack: PACK,
@@ -118,21 +141,21 @@ describe('mod registry', () => {
@@ -118,21 +141,21 @@ describe('mod registry', () => {
expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(false)
})
})
- it('refuses executable runtimes until sandboxed execution exists', () => {
- const { registry } = setup()
- expect(() =>
+ it('refuses executable runtimes until sandboxed execution exists', async () => {
+ const { registry } = await setup()
+ await expect(
registry.install(
registry.install(
{ manifest: sampleManifest({ runtime: 'source-quickjs@1' }), pack: {}, approve: [] },
{ manifest: sampleManifest({ runtime: 'source-quickjs@1' }), pack: {}, approve: [] },
STAFF,
STAFF,
),
),
- ).toThrow(/cannot run here/)
+ ).rejects.toThrow(/cannot run here/)
})
})
})
})
describe('mod runtime seam', () => {
describe('mod runtime seam', () => {
- it('a denied capability cannot be reached: the binding throws and the host is never called', () => {
- const { registry, runtime, clock } = setup()
- registry.install(
+ it('a denied capability cannot be reached: the binding throws and the host is never called', async () => {
+ const { registry, runtime, clock } = await setup()
+ await registry.install(
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
STAFF,
STAFF,
)
)
@@ -156,13 +179,14 @@ describe('mod runtime seam', () => {
@@ -156,13 +179,14 @@ describe('mod runtime seam', () => {
runtime.tick(t, host)
runtime.tick(t, host)
}
}
expect(said).toEqual([])
expect(said).toEqual([])
- const denials = registry.auditLog(MOD_ID).filter((a) => a.action === 'deny')
+ await registry.flushWrites()
+ const denials = (await registry.auditLog(MOD_ID)).filter((a) => a.action === 'deny')
expect(denials.map((a) => a.capability)).toEqual([CAP_ANNOUNCE])
expect(denials.map((a) => a.capability)).toEqual([CAP_ANNOUNCE])
})
})
- it('places the pack, and a revoked mod stops affecting the world on the next tick', () => {
- const { registry, runtime, clock, sink } = setup()
- registry.install(
+ it('places the pack, and a revoked mod stops affecting the world on the next tick', async () => {
+ const { registry, runtime, clock, sink } = await setup()
+ await registry.install(
{
{
manifest: sampleManifest(),
manifest: sampleManifest(),
pack: PACK,
pack: PACK,
@@ -181,7 +205,7 @@ describe('mod runtime seam', () => {
@@ -181,7 +205,7 @@ describe('mod runtime seam', () => {
runtime.tick(31, host)
runtime.tick(31, host)
expect(said).toEqual(['[Town Square] Welcome to the square'])
expect(said).toEqual(['[Town Square] Welcome to the square'])
- registry.revoke(MOD_ID, STAFF, 'test')
+ await registry.revoke(MOD_ID, STAFF, 'test')
runtime.tick(32, host) // the very next tick
runtime.tick(32, host) // the very next tick
expect(entities.size).toBe(0)
expect(entities.size).toBe(0)
clock.advance(600_000)
clock.advance(600_000)
@@ -192,19 +216,20 @@ describe('mod runtime seam', () => {
@@ -192,19 +216,20 @@ describe('mod runtime seam', () => {
ModCapabilityError,
ModCapabilityError,
)
)
// Revoked is terminal.
// Revoked is terminal.
- expect(() => registry.enable(MOD_ID, STAFF)).toThrow(/stays revoked/)
- expect(() => registry.grant(MOD_ID, CAP_ANNOUNCE, STAFF)).toThrow(/revoked/)
+ await expect(registry.enable(MOD_ID, STAFF)).rejects.toThrow(/stays revoked/)
+ await expect(registry.grant(MOD_ID, CAP_ANNOUNCE, STAFF)).rejects.toThrow(/revoked/)
- const actions = registry.auditLog(MOD_ID).map((a) => a.action)
+ await registry.flushWrites()
+ const actions = (await registry.auditLog(MOD_ID)).map((a) => a.action)
for (const a of ['install', 'grant', 'use', 'revoke_grant', 'revoke', 'retract']) {
for (const a of ['install', 'grant', 'use', 'revoke_grant', 'revoke', 'retract']) {
expect(actions, a).toContain(a)
expect(actions, a).toContain(a)
}
}
expect(sink.types).toEqual(['games.mod.installed', 'games.mod.revoked'])
expect(sink.types).toEqual(['games.mod.installed', 'games.mod.revoked'])
})
})
- it('revoking one capability retracts only what it allowed', () => {
- const { registry, runtime, sink } = setup()
- registry.install(
+ it('revoking one capability retracts only what it allowed', async () => {
+ const { registry, runtime, sink } = await setup()
+ await registry.install(
{
{
manifest: sampleManifest(),
manifest: sampleManifest(),
pack: PACK,
pack: PACK,
@@ -216,11 +241,11 @@ describe('mod runtime seam', () => {
@@ -216,11 +241,11 @@ describe('mod runtime seam', () => {
const { host, entities } = fakeHost()
const { host, entities } = fakeHost()
runtime.tick(1, host)
runtime.tick(1, host)
expect(entities.size).toBe(2)
expect(entities.size).toBe(2)
- registry.revokeGrant(MOD_ID, CAP_PLACE_PROP, STAFF)
+ await registry.revokeGrant(MOD_ID, CAP_PLACE_PROP, STAFF)
runtime.tick(2, host)
runtime.tick(2, host)
expect(entities.size).toBe(0)
expect(entities.size).toBe(0)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
- registry.grant(MOD_ID, CAP_PLACE_PROP, STAFF)
+ await registry.grant(MOD_ID, CAP_PLACE_PROP, STAFF)
runtime.tick(3, host)
runtime.tick(3, host)
expect(entities.size).toBe(2)
expect(entities.size).toBe(2)
expect(sink.types).toEqual([
expect(sink.types).toEqual([
@@ -230,18 +255,18 @@ describe('mod runtime seam', () => {
@@ -230,18 +255,18 @@ describe('mod runtime seam', () => {
])
])
})
})
- it('disable retracts, enable restores, and a missing prop is placed again', () => {
- const { registry, runtime } = setup()
- registry.install(
+ it('disable retracts, enable restores, and a missing prop is placed again', async () => {
+ const { registry, runtime } = await setup()
+ await registry.install(
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
STAFF,
STAFF,
)
)
const { host, entities } = fakeHost()
const { host, entities } = fakeHost()
runtime.tick(1, host)
runtime.tick(1, host)
- registry.disable(MOD_ID, STAFF)
+ await registry.disable(MOD_ID, STAFF)
runtime.tick(2, host)
runtime.tick(2, host)
expect(entities.size).toBe(0)
expect(entities.size).toBe(0)
- registry.enable(MOD_ID, STAFF)
+ await registry.enable(MOD_ID, STAFF)
runtime.tick(3, host)
runtime.tick(3, host)
expect(entities.size).toBe(2)
expect(entities.size).toBe(2)
const [first] = [...entities.keys()]
const [first] = [...entities.keys()]
@@ -250,28 +275,113 @@ describe('mod runtime seam', () => {
@@ -250,28 +275,113 @@ describe('mod runtime seam', () => {
expect(entities.size).toBe(1)
expect(entities.size).toBe(1)
runtime.tick(3 + 30, host)
runtime.tick(3 + 30, host)
expect(entities.size).toBe(2)
expect(entities.size).toBe(2)
+ await registry.flushWrites()
+ })
+
+ it('a failed disable write keeps an over-budget mod running: strikes are not cleared', async () => {
+ const store = await openTestStore()
+ let fail = true
+ const { registry, runtime } = await setup(failingDisableStore(store, () => fail))
+ await registry.install(
+ {
+ manifest: sampleManifest({ resources: { cpuMs: 0, memoryMb: 0, storageMb: 0 } }),
+ pack: PACK,
+ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],
+ enable: true,
+ },
+ STAFF,
+ )
+ const { host } = fakeHost()
+ // Drive enough reconciles for CPU_STRIKES to trip the budget; let each failed disable settle so the
+ // next reconcile enforces again instead of being suppressed as already in flight.
+ for (let t = 1; t <= 200; t += 30) {
+ runtime.tick(t, host)
+ await settle()
+ }
+ await registry.flushWrites()
+ expect(registry.isActive(MOD_ID)).toBe(true) // the disable never landed
+ const enforced = (await registry.auditLog(MOD_ID)).filter(
+ (a) => a.action === 'budget_enforced',
+ ).length
+ expect(enforced).toBeGreaterThanOrEqual(2) // it kept being enforced, not reset to a clean slate
+
+ // The write lands now: the next reconcile disables it for good.
+ fail = false
+ for (const t of [211, 241, 271, 301]) {
+ runtime.tick(t, host)
+ await settle()
+ }
+ expect(registry.isActive(MOD_ID)).toBe(false)
+ await registry.flushWrites()
+ })
+
+ it('a placement write that keeps failing rolls the mirror back and the next tick re-places', async () => {
+ const store = await openTestStore()
+ let fail = true
+ const wrapped = {
+ ...store,
+ mods: {
+ ...store.mods,
+ setPlacement: async (placement: Parameters<typeof store.mods.setPlacement>[0]) => {
+ if (fail) throw new Error('db down')
+ await store.mods.setPlacement(placement)
+ },
+ },
+ } as PgPersistenceStore
+ const { registry, runtime } = await setup(wrapped)
+ await registry.install(
+ {
+ manifest: sampleManifest(),
+ pack: PACK,
+ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],
+ enable: true,
+ },
+ STAFF,
+ )
+ const { host } = fakeHost()
+ runtime.tick(1, host)
+ await registry.flushWrites()
+ // The writes gave up: the mirror was rolled back so it agrees with the database (empty).
+ expect(registry.placements(MOD_ID)).toEqual([])
+ expect(registry.writeFailures).toBeGreaterThanOrEqual(1)
+ expect(await store.mods.placements(MOD_ID)).toEqual([])
+
+ // The database recovers: the next reconcile places the props again and the rows land.
+ fail = false
+ runtime.tick(31, host)
+ await registry.flushWrites()
+ expect(
+ registry
+ .placements(MOD_ID)
+ .map((p) => p.key)
+ .sort(),
+ ).toEqual(['bench', 'fire'])
+ expect((await store.mods.placements(MOD_ID)).map((p) => p.key).sort()).toEqual([
+ 'bench',
+ 'fire',
+ ])
})
})
- it('revocation survives a restart: the next boot retracts what the old process left', () => {
- const dir = mkdtempSync(join(tmpdir(), 'openvibe-mods-'))
- const path = join(dir, 'world.db')
- const a = setup(openSqliteStore(path))
- a.registry.install(
+ it('revocation survives a restart: the next boot retracts what the old process left', async () => {
+ const store = await openTestStore()
+ const a = await setup(store)
+ await a.registry.install(
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
{ manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
STAFF,
STAFF,
)
)
const world = fakeHost()
const world = fakeHost()
a.runtime.tick(1, world.host)
a.runtime.tick(1, world.host)
expect(world.entities.size).toBe(2)
expect(world.entities.size).toBe(2)
- a.registry.revoke(MOD_ID, STAFF)
+ await a.registry.revoke(MOD_ID, STAFF)
// The process dies before its next tick: the props are still in the world.
// The process dies before its next tick: the props are still in the world.
- a.store.close()
+ await a.registry.flushWrites()
expect(world.entities.size).toBe(2)
expect(world.entities.size).toBe(2)
- const b = setup(openSqliteStore(path))
+ const b = await setup(store)
+ await b.registry.load()
expect(b.registry.get(MOD_ID)?.mod.status).toBe('revoked')
expect(b.registry.get(MOD_ID)?.mod.status).toBe('revoked')
b.runtime.tick(1, world.host)
b.runtime.tick(1, world.host)
expect(world.entities.size).toBe(0)
expect(world.entities.size).toBe(0)
- b.store.close()
+ await b.registry.flushWrites()
})
})
})
})
@@ -28,7 +28,6 @@
@@ -28,7 +28,6 @@
* on that very tick.
* on that very tick.
*/
*/
import type { ContentRegistry } from '@openvibe/content'
import type { ContentRegistry } from '@openvibe/content'
-import type { PersistenceStore } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'
import { SYSTEM_ACTOR, type ModRegistry, type ModView } from './registry.js'
import { SYSTEM_ACTOR, type ModRegistry, type ModView } from './registry.js'
@@ -98,6 +97,8 @@ export class ModRuntime {
@@ -98,6 +97,8 @@ export class ModRuntime {
private readonly budgetNotedAt = new Map<string, number>()
private readonly budgetNotedAt = new Map<string, number>()
/** Reconciles in a row over the CPU budget, per mod. */
/** Reconciles in a row over the CPU budget, per mod. */
private readonly cpuStrikes = new Map<string, number>()
private readonly cpuStrikes = new Map<string, number>()
+ /** Mods whose disable write is in flight (an over-budget mod is enforced once, not every tick). */
+ private readonly enforcing = new Set<string>()
/** Host effects per mod in the current minute, and minutes in a row that were throttled. */
/** Host effects per mod in the current minute, and minutes in a row that were throttled. */
private readonly effects = new Map<
private readonly effects = new Map<
string,
string,
@@ -107,7 +108,6 @@ export class ModRuntime {
@@ -107,7 +108,6 @@ export class ModRuntime {
constructor(
constructor(
private readonly registry: ModRegistry,
private readonly registry: ModRegistry,
- private readonly store: PersistenceStore,
private readonly content: ContentRegistry,
private readonly content: ContentRegistry,
private readonly log: Logger,
private readonly log: Logger,
private readonly opts: ModRuntimeOptions,
private readonly opts: ModRuntimeOptions,
@@ -143,21 +143,21 @@ export class ModRuntime {
@@ -143,21 +143,21 @@ export class ModRuntime {
if (!pos.every((v) => Number.isFinite(v)) || !Number.isFinite(yaw)) {
if (!pos.every((v) => Number.isFinite(v)) || !Number.isFinite(yaw)) {
throw new Error('position must be finite')
throw new Error('position must be finite')
}
}
- const existing = this.store.mods.placements(modId).find((p) => p.key === key)
+ const existing = this.registry.placements(modId).find((p) => p.key === key)
if (existing?.entityId && host.entityExists(existing.entityId)) {
if (existing?.entityId && host.entityExists(existing.entityId)) {
host.removeEntity(existing.entityId)
host.removeEntity(existing.entityId)
}
}
const entityId = host.placeProp({ item, pos: [pos[0], pos[1], pos[2]], yaw, owner: modId })
const entityId = host.placeProp({ item, pos: [pos[0], pos[1], pos[2]], yaw, owner: modId })
- this.store.mods.setPlacement({ modId, key, entityId, at: this.now() })
+ this.registry.place(modId, key, entityId)
this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, item, entity: entityId })
this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, item, entity: entityId })
return entityId
return entityId
},
},
removeProp: (key) => {
removeProp: (key) => {
check(CAP_PLACE_PROP)
check(CAP_PLACE_PROP)
- const existing = this.store.mods.placements(modId).find((p) => p.key === key)
+ const existing = this.registry.placements(modId).find((p) => p.key === key)
if (!existing) return false
if (!existing) return false
if (existing.entityId) host.removeEntity(existing.entityId)
if (existing.entityId) host.removeEntity(existing.entityId)
- this.store.mods.deletePlacement(modId, key)
+ this.registry.unplace(modId, key)
this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, removed: true })
this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, removed: true })
return true
return true
},
},
@@ -198,7 +198,7 @@ export class ModRuntime {
@@ -198,7 +198,7 @@ export class ModRuntime {
this.retractProps(id, host)
this.retractProps(id, host)
if (props.length > 0) this.noteDenied(id, CAP_PLACE_PROP)
if (props.length > 0) this.noteDenied(id, CAP_PLACE_PROP)
} else {
} else {
- const placed = new Map(this.store.mods.placements(id).map((p) => [p.key, p]))
+ const placed = new Map(this.registry.placements(id).map((p) => [p.key, p]))
const wanted = new Set(props.map((p) => p.key))
const wanted = new Set(props.map((p) => p.key))
for (const prop of props) {
for (const prop of props) {
const p = placed.get(prop.key)
const p = placed.get(prop.key)
@@ -276,22 +276,36 @@ export class ModRuntime {
@@ -276,22 +276,36 @@ export class ModRuntime {
reason: 'cpu' | 'event_flood',
reason: 'cpu' | 'event_flood',
detail: Record<string, unknown>,
detail: Record<string, unknown>,
): void {
): void {
- if (!this.registry.isActive(id)) return
+ if (!this.registry.isActive(id) || this.enforcing.has(id)) return
this.registry.audit(id, 'budget_enforced', null, { reason, ...detail })
this.registry.audit(id, 'budget_enforced', null, { reason, ...detail })
- this.registry.disable(id, SYSTEM_ACTOR)
- this.cpuStrikes.delete(id)
- this.effects.delete(id)
- this.log.warn('mod disabled: over its budget', { mod: id, reason })
+ // The registry write is async; the tick never awaits it. Strikes and the effects window are cleared
+ // only once the disable committed: if the write fails the mod keeps them, so the next reconcile
+ // enforces again instead of leaving an over-budget mod running with a clean slate.
+ this.enforcing.add(id)
+ void this.registry
+ .disable(id, SYSTEM_ACTOR)
+ .then(() => {
+ this.cpuStrikes.delete(id)
+ this.effects.delete(id)
+ this.log.warn('mod disabled: over its budget', { mod: id, reason })
+ })
+ .catch((err: unknown) => {
+ this.log.warn('mod disable failed', {
+ mod: id,
+ error: String((err as Error)?.message ?? err),
+ })
+ })
+ .finally(() => this.enforcing.delete(id))
}
}
/** Despawns everything the mod placed. */
/** Despawns everything the mod placed. */
private retractProps(id: string, host: ModHost): void {
private retractProps(id: string, host: ModHost): void {
- const placements = this.store.mods.placements(id)
+ const placements = [...this.registry.placements(id)]
if (placements.length === 0) return
if (placements.length === 0) return
let removed = 0
let removed = 0
for (const p of placements) {
for (const p of placements) {
if (p.entityId && host.removeEntity(p.entityId)) removed++
if (p.entityId && host.removeEntity(p.entityId)) removed++
- this.store.mods.deletePlacement(id, p.key)
+ this.registry.unplace(id, p.key)
}
}
this.registry.audit(id, 'retract', CAP_PLACE_PROP, { placements: placements.length, removed })
this.registry.audit(id, 'retract', CAP_PLACE_PROP, { placements: placements.length, removed })
this.log.info('mod props retracted', { mod: id, removed })
this.log.info('mod props retracted', { mod: id, removed })
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { createContent } from '@openvibe/content'
import { createContent } from '@openvibe/content'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'
import { ModRegistry, type ModActor } from './registry.js'
import { ModRegistry, type ModActor } from './registry.js'
import {
import {
@@ -55,12 +55,12 @@ function world(opts: { slowMs?: number } = {}) {
@@ -55,12 +55,12 @@ function world(opts: { slowMs?: number } = {}) {
return { host, entities, said }
return { host, entities, said }
}
}
-function setup(effectsPerMinute = 60) {
+async function setup(effectsPerMinute = 60) {
let now = 1_000_000
let now = 1_000_000
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
- const store = openSqliteStore(':memory:')
+ const store = await openTestStore()
const registry = new ModRegistry(store, content, undefined, clock.now)
const registry = new ModRegistry(store, content, undefined, clock.now)
- const runtime = new ModRuntime(registry, store, content, log, {
+ const runtime = new ModRuntime(registry, content, log, {
reconcileEveryTicks: 1,
reconcileEveryTicks: 1,
now: clock.now,
now: clock.now,
effectsPerMinute,
effectsPerMinute,
@@ -85,11 +85,11 @@ const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({
@@ -85,11 +85,11 @@ const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({
})
})
describe('mod security', () => {
describe('mod security', () => {
- it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {
- const { registry, runtime, install } = setup()
+ it('namespace escape: a mod reaches only its own placements, through frozen bindings', async () => {
+ const { registry, runtime, install } = await setup()
const w = world()
const w = world()
- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
- install(B, { props: [prop('bench', [20, 0, 20])] }, [CAP_PLACE_PROP])
+ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
+ await install(B, { props: [prop('bench', [20, 0, 20])] }, [CAP_PLACE_PROP])
runtime.tick(1, w.host)
runtime.tick(1, w.host)
expect([...w.entities.values()].map((e) => e.owner).sort()).toEqual([A, B])
expect([...w.entities.values()].map((e) => e.owner).sort()).toEqual([A, B])
const apiA = runtime.bindingsFor(A, w.host)
const apiA = runtime.bindingsFor(A, w.host)
@@ -101,10 +101,11 @@ describe('mod security', () => {
@@ -101,10 +101,11 @@ describe('mod security', () => {
expect(apiA.removeProp('bench')).toBe(true)
expect(apiA.removeProp('bench')).toBe(true)
expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])
expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])
expect(apiA.removeProp('bench')).toBe(false)
expect(apiA.removeProp('bench')).toBe(false)
+ await registry.flushWrites()
expect(
expect(
- registry
- .auditLog(B)
- .some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed')),
+ (await registry.auditLog(B)).some(
+ (a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed'),
+ ),
).toBe(false)
).toBe(false)
// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install
// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install
// and at the binding.
// and at the binding.
@@ -116,11 +117,11 @@ describe('mod security', () => {
@@ -116,11 +117,11 @@ describe('mod security', () => {
expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)
expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)
})
})
- it('event flood: past the allowance calls are refused, and only the flooding mod is disabled', () => {
- const { registry, runtime, clock, install } = setup(10)
+ it('event flood: past the allowance calls are refused, and only the flooding mod is disabled', async () => {
+ const { registry, runtime, clock, install } = await setup(10)
const w = world()
const w = world()
- install(A, { announcements: [{ text: 'spam', everySeconds: 60 }] }, [CAP_ANNOUNCE])
- install(B, { announcements: [{ text: 'hello', everySeconds: 60 }] }, [CAP_ANNOUNCE])
+ await install(A, { announcements: [{ text: 'spam', everySeconds: 60 }] }, [CAP_ANNOUNCE])
+ await install(B, { announcements: [{ text: 'hello', everySeconds: 60 }] }, [CAP_ANNOUNCE])
const apiA = runtime.bindingsFor(A, w.host)
const apiA = runtime.bindingsFor(A, w.host)
const apiB = runtime.bindingsFor(B, w.host)
const apiB = runtime.bindingsFor(B, w.host)
let refused = 0
let refused = 0
@@ -133,7 +134,8 @@ describe('mod security', () => {
@@ -133,7 +134,8 @@ describe('mod security', () => {
}
}
expect(w.said.length).toBe(10)
expect(w.said.length).toBe(10)
expect(refused).toBe(15)
expect(refused).toBe(15)
- expect(registry.auditLog(A).filter((a) => a.action === 'throttled').length).toBe(1)
+ await registry.flushWrites()
+ expect((await registry.auditLog(A)).filter((a) => a.action === 'throttled').length).toBe(1)
apiB.announce('still here')
apiB.announce('still here')
expect(w.said.at(-1)).toContain('still here')
expect(w.said.at(-1)).toContain('still here')
// Flooding minute after minute: the mod is disabled, the other is not.
// Flooding minute after minute: the mod is disabled, the other is not.
@@ -147,39 +149,42 @@ describe('mod security', () => {
@@ -147,39 +149,42 @@ describe('mod security', () => {
}
}
}
}
}
}
- expect(registry.get(A)?.mod.status).toBe('disabled')
+ // The disable the flood triggers is a fire-and-forget registry write; wait for it to commit.
+ await vi.waitFor(() => expect(registry.get(A)?.mod.status).toBe('disabled'))
+ await registry.flushWrites()
expect(
expect(
- registry
- .auditLog(A)
- .some(
- (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),
- ),
+ (await registry.auditLog(A)).some(
+ (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),
+ ),
).toBe(true)
).toBe(true)
expect(registry.get(B)?.mod.status).toBe('enabled')
expect(registry.get(B)?.mod.status).toBe('enabled')
expect(() => apiA.announce('after')).toThrow()
expect(() => apiA.announce('after')).toThrow()
})
})
- it('resource budget: a mod over its CPU budget reconcile after reconcile is disabled; the others run', () => {
- const { registry, runtime, install } = setup()
+ it('resource budget: a mod over its CPU budget reconcile after reconcile is disabled; the others run', async () => {
+ const { registry, runtime, install } = await setup()
const slow = world({ slowMs: 3 })
const slow = world({ slowMs: 3 })
- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP], 1)
- install(B, { props: [] }, [], 50)
+ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP], 1)
+ await install(B, { props: [] }, [], 50)
// A's prop keeps vanishing, so every reconcile places it again: slow, over its 1 ms budget.
// A's prop keeps vanishing, so every reconcile places it again: slow, over its 1 ms budget.
for (let t = 1; t <= CPU_STRIKES + 2; t++) {
for (let t = 1; t <= CPU_STRIKES + 2; t++) {
slow.entities.clear()
slow.entities.clear()
runtime.tick(t, slow.host)
runtime.tick(t, slow.host)
}
}
- expect(registry.get(A)?.mod.status).toBe('disabled')
- const enforced = registry.auditLog(A).find((a) => a.action === 'budget_enforced')
+ // The disable the budget triggers is a fire-and-forget registry write; wait for it to commit.
+ await vi.waitFor(() => expect(registry.get(A)?.mod.status).toBe('disabled'))
+ await registry.flushWrites()
+ const enforced = (await registry.auditLog(A)).find((a) => a.action === 'budget_enforced')
expect(enforced?.detail).toMatchObject({ reason: 'cpu', budget_ms: 1, reconciles: CPU_STRIKES })
expect(enforced?.detail).toMatchObject({ reason: 'cpu', budget_ms: 1, reconciles: CPU_STRIKES })
expect(registry.get(B)?.mod.status).toBe('enabled')
expect(registry.get(B)?.mod.status).toBe('enabled')
// Disabled means retracted on the next reconcile.
// Disabled means retracted on the next reconcile.
runtime.tick(CPU_STRIKES + 3, slow.host)
runtime.tick(CPU_STRIKES + 3, slow.host)
expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])
expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])
+ await registry.flushWrites()
})
})
- it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", () => {
- const { registry, runtime, install } = setup()
+ it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", async () => {
+ const { registry, runtime, install } = await setup()
const w = world()
const w = world()
expect(
expect(
validateContentPack(
validateContentPack(
@@ -187,12 +192,12 @@ describe('mod security', () => {
@@ -187,12 +192,12 @@ describe('mod security', () => {
content,
content,
).ok,
).ok,
).toBe(false)
).toBe(false)
- install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
+ await install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
runtime.tick(1, w.host)
runtime.tick(1, w.host)
expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])
expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])
// A principal Games does not have, or that another runtime owns, is not applied here.
// A principal Games does not have, or that another runtime owns, is not applied here.
expect(
expect(
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: B,
mod_id: B,
status: 'active',
status: 'active',
approved: [CAP_PLACE_PROP, CAP_ANNOUNCE],
approved: [CAP_PLACE_PROP, CAP_ANNOUNCE],
@@ -202,7 +207,7 @@ describe('mod security', () => {
@@ -202,7 +207,7 @@ describe('mod security', () => {
// A grant the manifest never requested cannot be applied, even from Network.
// A grant the manifest never requested cannot be applied, even from Network.
const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })
const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })
expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')
expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')
- registry.applyNetwork({
+ await registry.applyNetwork({
mod_id: A,
mod_id: A,
status: 'active',
status: 'active',
approved: [CAP_PLACE_PROP, 'games.world.teleport'],
approved: [CAP_PLACE_PROP, 'games.world.teleport'],
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
*/
*/
import { createHash } from 'node:crypto'
import { createHash } from 'node:crypto'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
-import { createActorLimiter } from 'openvibe-sdk/limits'
+import { createActorLimiter, createValkeyLimitStore } from 'openvibe-sdk/limits'
+import type { Valkey } from 'openvibe-sdk/valkey'
type Req = IncomingMessage & { ovActor?: string }
type Req = IncomingMessage & { ovActor?: string }
const num = (v: string | undefined, d: number): number => {
const num = (v: string | undefined, d: number): number => {
@@ -22,13 +23,17 @@ export function createGamesActorLimits(
@@ -22,13 +23,17 @@ export function createGamesActorLimits(
env?: NodeJS.ProcessEnv
env?: NodeJS.ProcessEnv
now?: () => number
now?: () => number
onLimited?: (name: string, actor: string) => void
onLimited?: (name: string, actor: string) => void
+ /** Shared Valkey: one actor counts the same across every instance; null = in-process counters. */
+ valkey?: Valkey | null
} = {},
} = {},
) {
) {
const env = opts.env ?? process.env
const env = opts.env ?? process.env
+ const store = opts.valkey ? createValkeyLimitStore(opts.valkey) : null
const limiter = createActorLimiter({
const limiter = createActorLimiter({
limits: { minute: num(env.GAMES_LIMITS_MINUTE, 120), hour: num(env.GAMES_LIMITS_HOUR, 3000) },
limits: { minute: num(env.GAMES_LIMITS_MINUTE, 120), hour: num(env.GAMES_LIMITS_HOUR, 3000) },
actor: (req) => (req as Req).ovActor ?? null,
actor: (req) => (req as Req).ovActor ?? null,
...(opts.now ? { now: opts.now } : {}),
...(opts.now ? { now: opts.now } : {}),
+ ...(store ? { store } : {}),
onLimited: (e) => opts.onLimited?.(e.name, e.actor),
onLimited: (e) => opts.onLimited?.(e.name, e.actor),
})
})
// A map save validates and migrates the whole map and swaps it live; an asset is hashed and stored on disk.
// A map save validates and migrates the whole map and swaps it live; an asset is hashed and stored on disk.
@@ -90,7 +90,7 @@ beforeAll(async () => {
@@ -90,7 +90,7 @@ beforeAll(async () => {
metrics.tick = 42
metrics.tick = 42
metrics.lastTickAt = Date.now()
metrics.lastTickAt = Date.now()
// /api/ready arrives through the platform hook, as main.ts wires it.
// /api/ready arrives through the platform hook, as main.ts wires it.
- const readiness = createReadiness({ pingDb: () => true, metrics })
+ const readiness = createReadiness({ pingDb: async () => ({ ok: true }), metrics })
server = createHttpServer(
server = createHttpServer(
dist,
dist,
metrics,
metrics,
@@ -18,7 +18,12 @@ export class ServerMetrics {
@@ -18,7 +18,12 @@ export class ServerMetrics {
bytesOut = 0
bytesOut = 0
messagesOut = 0
messagesOut = 0
snapshotBytes = 0
snapshotBytes = 0
+ /** Write-behind flusher: jobs queued or running, rows written, duration and failure counters. */
dbDirtyQueue = 0
dbDirtyQueue = 0
+ persistRowsWritten = 0
+ persistFlushMs = 0
+ persistFlushFailures = 0
+ persistCoalesced = 0
/** Live constraints and the multi-prop structures they form. */
/** Live constraints and the multi-prop structures they form. */
constraints = 0
constraints = 0
constraintIslands = 0
constraintIslands = 0
@@ -59,6 +64,10 @@ export class ServerMetrics {
@@ -59,6 +64,10 @@ export class ServerMetrics {
messagesOut: this.messagesOut,
messagesOut: this.messagesOut,
snapshotBytes: this.snapshotBytes,
snapshotBytes: this.snapshotBytes,
dbDirtyQueue: this.dbDirtyQueue,
dbDirtyQueue: this.dbDirtyQueue,
+ persistRowsWritten: this.persistRowsWritten,
+ persistFlushMs: round2(this.persistFlushMs),
+ persistFlushFailures: this.persistFlushFailures,
+ persistCoalesced: this.persistCoalesced,
constraints: this.constraints,
constraints: this.constraints,
constraintIslands: this.constraintIslands,
constraintIslands: this.constraintIslands,
activeRegions: this.activeRegions,
activeRegions: this.activeRegions,
@@ -1,44 +1,26 @@
@@ -1,44 +1,26 @@
-import { mkdtempSync, rmSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { AddressInfo } from 'node:net'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TICK_STALL_MS, createReadiness } from './readiness.js'
import { TICK_STALL_MS, createReadiness } from './readiness.js'
const T0 = 1_750_000_000_000
const T0 = 1_750_000_000_000
-let dir: string
-let store: SqlitePersistenceStore
+type PingDb = () => Promise<{ ok: boolean; error?: string }>
-beforeEach(() => {
- dir = mkdtempSync(join(tmpdir(), 'ovg-ready-'))
- store = openSqliteStore(join(dir, 'world.db'))
-})
-
-afterEach(() => {
- try {
- store.close()
- } catch {
- // already closed by the test
- }
- rmSync(dir, { recursive: true, force: true })
-})
+const up: PingDb = async () => ({ ok: true })
-/** The same wiring main.ts uses, over a real world.db and a controllable clock. */
-function setup(metrics: { tick: number; lastTickAt: number }, clock = { now: T0 }) {
- const stmt = store.db.prepare('SELECT value FROM meta WHERE key = ?')
- return createReadiness({
- pingDb: () => stmt.get('schema_version') !== undefined,
- metrics,
- now: () => clock.now,
- })
+/** The same wiring main.ts uses, over a controllable database ping and a controllable clock. */
+function setup(
+ metrics: { tick: number; lastTickAt: number },
+ clock = { now: T0 },
+ pingDb: PingDb = up,
+) {
+ return createReadiness({ pingDb, metrics, now: () => clock.now })
}
}
describe('/api/ready', () => {
describe('/api/ready', () => {
- it('is ready when world.db answers and the tick is recent', () => {
- const body = setup({ tick: 120, lastTickAt: T0 - 40 }).check()
+ it('is ready when the database answers and the tick is recent', async () => {
+ const body = await setup({ tick: 120, lastTickAt: T0 - 40 }).check()
expect(body).toMatchObject({
expect(body).toMatchObject({
ready: true,
ready: true,
status: 'ready',
status: 'ready',
@@ -54,57 +36,69 @@ describe('/api/ready', () => {
@@ -54,57 +36,69 @@ describe('/api/ready', () => {
})
})
})
})
- it('reports the players online as a non-required sessions check (for the host protected probe)', () => {
- const stmt = store.db.prepare('SELECT value FROM meta WHERE key = ?')
+ it('reports the players online as a non-required sessions check (for the host protected probe)', async () => {
let online = 3
let online = 3
- const r = createReadiness({ pingDb: () => stmt.get('schema_version') !== undefined, metrics: { tick: 5, lastTickAt: T0 - 10 }, now: () => T0, online: () => online })
- let body = r.check()
- expect(body.checks.sessions).toMatchObject({ status: 'ok', required: false, detail: { online: 3 } })
+ const r = createReadiness({
+ pingDb: up,
+ metrics: { tick: 5, lastTickAt: T0 - 10 },
+ now: () => T0,
+ online: () => online,
+ })
+ let body = await r.check()
+ expect(body.checks.sessions).toMatchObject({
+ status: 'ok',
+ required: false,
+ detail: { online: 3 },
+ })
expect(body.status).toBe('ready')
expect(body.status).toBe('ready')
online = 0
online = 0
- body = r.check()
+ body = await r.check()
expect(body.checks.sessions?.detail).toEqual({ online: 0 })
expect(body.checks.sessions?.detail).toEqual({ online: 0 })
- expect(setup({ tick: 5, lastTickAt: T0 - 10 }).check().checks.sessions).toBeUndefined()
+ expect((await setup({ tick: 5, lastTickAt: T0 - 10 }).check()).checks.sessions).toBeUndefined()
})
})
- it('is not ready before the first tick', () => {
- const body = setup({ tick: 0, lastTickAt: 0 }).check()
+ it('is not ready before the first tick', async () => {
+ const body = await setup({ tick: 0, lastTickAt: 0 }).check()
expect(body.status).toBe('not_ready')
expect(body.status).toBe('not_ready')
expect(body.failed).toEqual(['tick'])
expect(body.failed).toEqual(['tick'])
expect(body.checks.tick?.error).toBe('simulation has not ticked yet')
expect(body.checks.tick?.error).toBe('simulation has not ticked yet')
})
})
- it('is not ready once the tick stops advancing', () => {
+ it('is not ready once the tick stops advancing', async () => {
const metrics = { tick: 300, lastTickAt: T0 }
const metrics = { tick: 300, lastTickAt: T0 }
const clock = { now: T0 + TICK_STALL_MS }
const clock = { now: T0 + TICK_STALL_MS }
const ready = setup(metrics, clock)
const ready = setup(metrics, clock)
- expect(ready.check().ready).toBe(true)
+ expect((await ready.check()).ready).toBe(true)
clock.now = T0 + TICK_STALL_MS + 1
clock.now = T0 + TICK_STALL_MS + 1
- const body = ready.check()
+ const body = await ready.check()
expect(body.ready).toBe(false)
expect(body.ready).toBe(false)
expect(body.failed).toEqual(['tick'])
expect(body.failed).toEqual(['tick'])
expect(body.checks.tick?.error).toMatch(/no simulation tick for \d+ ms/)
expect(body.checks.tick?.error).toMatch(/no simulation tick for \d+ ms/)
// The tick moves again: ready again.
// The tick moves again: ready again.
metrics.tick = 301
metrics.tick = 301
metrics.lastTickAt = clock.now
metrics.lastTickAt = clock.now
- expect(ready.check().ready).toBe(true)
+ expect((await ready.check()).ready).toBe(true)
})
})
- it('is not ready when world.db stops answering', () => {
- const ready = setup({ tick: 5, lastTickAt: T0 })
- store.close()
- const body = ready.check()
+ it('is not ready when the database stops answering', async () => {
+ let dbUp = true
+ const ready = setup({ tick: 5, lastTickAt: T0 }, { now: T0 }, async () =>
+ dbUp ? { ok: true } : { ok: false, error: 'connection refused' },
+ )
+ dbUp = false
+ const body = await ready.check()
expect(body.status).toBe('not_ready')
expect(body.status).toBe('not_ready')
expect(body.failed).toEqual(['db'])
expect(body.failed).toEqual(['db'])
expect(body.checks.db?.status).toBe('fail')
expect(body.checks.db?.status).toBe('fail')
- expect(body.checks.db?.error).toMatch(/not open/)
+ expect(body.checks.db?.error).toMatch(/connection refused/)
})
})
- it('is not ready when the query returns nothing', () => {
- store.db.prepare('DELETE FROM meta').run()
- const body = setup({ tick: 5, lastTickAt: T0 }).check()
+ it('is not ready when the ping fails without a reason', async () => {
+ const body = await setup({ tick: 5, lastTickAt: T0 }, { now: T0 }, async () => ({
+ ok: false,
+ })).check()
expect(body.failed).toEqual(['db'])
expect(body.failed).toEqual(['db'])
- expect(body.checks.db?.error).toBe('world.db query returned nothing')
+ expect(body.checks.db?.error).toBe('database did not answer')
})
})
describe('over HTTP', () => {
describe('over HTTP', () => {
@@ -145,6 +139,35 @@ describe('/api/ready', () => {
@@ -145,6 +139,35 @@ describe('/api/ready', () => {
expect(body.checks.tick.error).toMatch(/no simulation tick/)
expect(body.checks.tick.error).toMatch(/no simulation tick/)
})
})
+ it('answers 503 rather than hanging when the probe itself rejects', async () => {
+ const broken = createReadiness({
+ pingDb: up,
+ metrics: { tick: 10, lastTickAt: T0 },
+ // checked_at is assembled outside run()'s try, so a throwing clock rejects check() outright.
+ now: () => {
+ throw new Error('clock broke')
+ },
+ })
+ await expect(broken.check()).rejects.toThrow('clock broke')
+ const probe = createServer((req, res) => {
+ if (broken.handle(req, res)) return
+ res.writeHead(404)
+ res.end()
+ })
+ await new Promise<void>((r) => probe.listen(0, '127.0.0.1', r))
+ try {
+ const res = await fetch(
+ `http://127.0.0.1:${(probe.address() as AddressInfo).port}/api/ready`,
+ )
+ expect(res.status).toBe(503)
+ expect(res.headers.get('content-type')).toBe('application/json; charset=utf-8')
+ expect(res.headers.get('cache-control')).toBe('no-store')
+ expect(await res.json()).toEqual({ error: 'probe_failed' })
+ } finally {
+ await new Promise<void>((r) => probe.close(() => r()))
+ }
+ })
+
it('leaves other paths alone and refuses writes', async () => {
it('leaves other paths alone and refuses writes', async () => {
expect((await fetch(`${base}/api/readyz`)).status).toBe(404)
expect((await fetch(`${base}/api/readyz`)).status).toBe(404)
expect((await fetch(`${base}/api/ready`, { method: 'POST' })).status).toBe(405)
expect((await fetch(`${base}/api/ready`, { method: 'POST' })).status).toBe(405)
@@ -8,8 +8,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -8,8 +8,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
* 200 when every required check passes, 503 otherwise; each failed check
* 200 when every required check passes, 503 otherwise; each failed check
* carries its reason in `error`.
* carries its reason in `error`.
*
*
- * Required: `db` (world.db answers a trivial query) and `tick` (the fixed-tick
- * simulation has advanced recently). /healthz stays liveness only.
+ * Required: `db` (the PostgreSQL handle answers a real round trip through db.ready()) and `tick` (the
+ * fixed-tick simulation has advanced recently). /healthz stays liveness only.
*/
*/
/**
/**
@@ -41,8 +41,8 @@ export interface ReadinessBody {
@@ -41,8 +41,8 @@ export interface ReadinessBody {
}
}
export interface ReadinessDeps {
export interface ReadinessDeps {
- /** Runs a trivial query against world.db; throws or returns false when it fails. */
- pingDb: () => boolean
+ /** A real round trip against the database (openvibe-sdk/db db.ready()). */
+ pingDb: () => Promise<{ ok: boolean; error?: string }>
/** The tick counter and when the last tick completed (epoch ms, 0 = never). */
/** The tick counter and when the last tick completed (epoch ms, 0 = never). */
metrics: { readonly tick: number; readonly lastTickAt: number }
metrics: { readonly tick: number; readonly lastTickAt: number }
/** Players connected now (a non-required `sessions` check; OpenVibe.Host's protected probe reads it). */
/** Players connected now (a non-required `sessions` check; OpenVibe.Host's protected probe reads it). */
@@ -59,11 +59,15 @@ function reason(err: unknown): string {
@@ -59,11 +59,15 @@ function reason(err: unknown): string {
return (text.split('\n')[0] ?? '').slice(0, 200) || 'failed'
return (text.split('\n')[0] ?? '').slice(0, 200) || 'failed'
}
}
-function run(fn: () => Outcome, required: boolean, now: () => number): ReadinessCheck {
+async function run(
+ fn: () => Outcome | Promise<Outcome>,
+ required: boolean,
+ now: () => number,
+): Promise<ReadinessCheck> {
const t0 = performance.now()
const t0 = performance.now()
let out: Outcome
let out: Outcome
try {
try {
- out = fn()
+ out = await fn()
} catch (err) {
} catch (err) {
out = { ok: false, error: reason(err) }
out = { ok: false, error: reason(err) }
}
}
@@ -78,39 +82,40 @@ function run(fn: () => Outcome, required: boolean, now: () => number): Readiness
@@ -78,39 +82,40 @@ function run(fn: () => Outcome, required: boolean, now: () => number): Readiness
}
}
export function createReadiness(deps: ReadinessDeps): {
export function createReadiness(deps: ReadinessDeps): {
- check: () => ReadinessBody
+ check: () => Promise<ReadinessBody>
handle: (req: IncomingMessage, res: ServerResponse) => boolean
handle: (req: IncomingMessage, res: ServerResponse) => boolean
} {
} {
const now = deps.now ?? Date.now
const now = deps.now ?? Date.now
const stallMs = deps.tickStallMs ?? TICK_STALL_MS
const stallMs = deps.tickStallMs ?? TICK_STALL_MS
- function check(): ReadinessBody {
- const checks: Record<string, ReadinessCheck> = {
- db: run(
- () =>
- deps.pingDb() ? { ok: true } : { ok: false, error: 'world.db query returned nothing' },
- true,
- now,
- ),
- tick: run(
- () => {
- const { tick, lastTickAt } = deps.metrics
- const at = now()
- const ageMs = lastTickAt > 0 ? at - lastTickAt : null
- const detail = { tick, age_ms: ageMs, threshold_ms: stallMs }
- if (ageMs === null) return { ok: false, error: 'simulation has not ticked yet', detail }
- if (ageMs > stallMs) {
- return { ok: false, error: `no simulation tick for ${Math.round(ageMs)} ms`, detail }
- }
- return { ok: true, detail }
- },
- true,
- now,
- ),
- }
+ async function check(): Promise<ReadinessBody> {
+ const db = await run(
+ async () => {
+ const r = await deps.pingDb()
+ return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'database did not answer' }
+ },
+ true,
+ now,
+ )
+ const tick = await run(
+ () => {
+ const { tick, lastTickAt } = deps.metrics
+ const at = now()
+ const ageMs = lastTickAt > 0 ? at - lastTickAt : null
+ const detail = { tick, age_ms: ageMs, threshold_ms: stallMs }
+ if (ageMs === null) return { ok: false, error: 'simulation has not ticked yet', detail }
+ if (ageMs > stallMs) {
+ return { ok: false, error: `no simulation tick for ${Math.round(ageMs)} ms`, detail }
+ }
+ return { ok: true, detail }
+ },
+ true,
+ now,
+ )
+ const checks: Record<string, ReadinessCheck> = { db, tick }
if (deps.online) {
if (deps.online) {
const online = deps.online
const online = deps.online
- checks.sessions = run(() => ({ ok: true, detail: { online: online() } }), false, now)
+ checks.sessions = await run(() => ({ ok: true, detail: { online: online() } }), false, now)
}
}
const names = Object.keys(checks)
const names = Object.keys(checks)
const failed = names.filter((n) => checks[n]?.required && checks[n]?.status !== 'ok')
const failed = names.filter((n) => checks[n]?.required && checks[n]?.status !== 'ok')
@@ -134,12 +139,25 @@ export function createReadiness(deps: ReadinessDeps): {
@@ -134,12 +139,25 @@ export function createReadiness(deps: ReadinessDeps): {
res.end('{"error":"method_not_allowed"}')
res.end('{"error":"method_not_allowed"}')
return true
return true
}
}
- const body = check()
- res.writeHead(body.ready ? 200 : 503, {
- 'content-type': 'application/json; charset=utf-8',
- 'cache-control': 'no-store',
- })
- res.end(req.method === 'HEAD' ? undefined : JSON.stringify(body))
+ void check()
+ .then((body) => {
+ if (res.writableEnded) return
+ res.writeHead(body.ready ? 200 : 503, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(body))
+ })
+ // A throw outside run()'s try (body assembly, header writes) must not leave the probe hanging
+ // or produce an unhandled rejection: answer not-ready like the check itself would.
+ .catch(() => {
+ if (res.writableEnded) return
+ res.writeHead(503, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ })
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify({ error: 'probe_failed' }))
+ })
return true
return true
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import { openTestStore } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
@@ -46,8 +46,8 @@ const ev = (type: string, payload: Record<string, unknown>, source = 'network'):
@@ -46,8 +46,8 @@ const ev = (type: string, payload: Record<string, unknown>, source = 'network'):
describe('account export and deletion (ADR-033)', () => {
describe('account export and deletion (ADR-033)', () => {
it('exports, then closes sessions, erases and confirms once', async () => {
it('exports, then closes sessions, erases and confirms once', async () => {
- const store = openSqliteStore(':memory:')
- store.players.upsertMany([char('d0', DANA), char('d1', OLD), char('o0', OTTO)])
+ const store = await openTestStore()
+ await store.players.upsertMany([char('d0', DANA), char('d1', OLD), char('o0', OTTO)])
const sent: {
const sent: {
path: string
path: string
body: {
body: {
@@ -103,8 +103,10 @@ describe('account export and deletion (ADR-033)', () => {
@@ -103,8 +103,10 @@ describe('account export and deletion (ADR-033)', () => {
failNext = true
failNext = true
await expect(data.apply(del)).rejects.toThrow(/confirmation refused: 503/)
await expect(data.apply(del)).rejects.toThrow(/confirmation refused: 503/)
expect(closed).toEqual([[DANA, OLD]])
expect(closed).toEqual([[DANA, OLD]])
- expect(store.players.listByToken(DANA).length + store.players.listByToken(OLD).length).toBe(0)
- expect(store.players.listByToken(OTTO).map((p) => p.id)).toEqual(['o0'])
+ const dana = await store.players.listByToken(DANA)
+ const old = await store.players.listByToken(OLD)
+ expect(dana.length + old.length).toBe(0)
+ expect((await store.players.listByToken(OTTO)).map((p) => p.id)).toEqual(['o0'])
expect(await data.apply(del)).toBe('confirmed')
expect(await data.apply(del)).toBe('confirmed')
expect(closed.length).toBe(1)
expect(closed.length).toBe(1)
const conf = sent[1]!
const conf = sent[1]!
@@ -116,6 +118,6 @@ describe('account export and deletion (ADR-033)', () => {
@@ -116,6 +118,6 @@ describe('account export and deletion (ADR-033)', () => {
expect(conf.body.retained).toEqual({ structures_unowned: 0 })
expect(conf.body.retained).toEqual({ structures_unowned: 0 })
expect(await data.apply(del)).toBe('unchanged')
expect(await data.apply(del)).toBe('unchanged')
expect(sent.length).toBe(2)
expect(sent.length).toBe(2)
- store.close()
+ await store.close()
})
})
})
})
@@ -1,17 +1,17 @@
@@ -1,17 +1,17 @@
/**
/**
* Account export and deletion → Games (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at POST
* Account export and deletion → Games (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at POST
* /internal/events (./revocationEvents.ts) and are applied once per export or deletion (account_data_events, kept by
* /internal/events (./revocationEvents.ts) and are applied once per export or deletion (account_data_events, kept by
- * this adapter beside the outbox); the delivery is answered after Network took the part or the confirmation, so a
- * failure is redelivered without erasing twice.
+ * this adapter beside the outbox; the table is in the persistence migration); the delivery is answered after Network
+ * took the part or the confirmation, so a failure is redelivered without erasing twice.
*
*
* network.account.export_requested Games' part (POST /internal/account-exports/:id/parts, service token):
* network.account.export_requested Games' part (POST /internal/account-exports/:id/parts, service token):
- * characters.json (without the sign-in token) and structures.json.
+ * characters.json (without the account key) and structures.json.
* network.account.deleted the person's game sessions are closed first (each close saves its character),
* network.account.deleted the person's game sessions are closed first (each close saves its character),
* then store.identity.eraseSubjects removes their characters and adoption rows;
* then store.identity.eraseSubjects removes their characters and adoption rows;
* their structures stay in the world without an owner. Games then confirms
* their structures stay in the world without an owner. Games then confirms
* with counts.
* with counts.
*/
*/
-import type Database from 'better-sqlite3'
+import { sql, type Db } from 'openvibe-sdk/db'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { IdentityRepository } from '@openvibe/persistence'
import type { IdentityRepository } from '@openvibe/persistence'
@@ -33,31 +33,25 @@ export type Send = (path: string, body: unknown) => Promise<{ ok: boolean; statu
@@ -33,31 +33,25 @@ export type Send = (path: string, body: unknown) => Promise<{ ok: boolean; statu
interface Record_ {
interface Record_ {
id: string
id: string
- outcome: string | null
+ outcome: unknown
sent_at: string | null
sent_at: string | null
applied_at: string
applied_at: string
}
}
export function createAccountData(opts: {
export function createAccountData(opts: {
- db: Database.Database
+ db: Db
identity: IdentityRepository
identity: IdentityRepository
send: Send
send: Send
/** Close the subjects' game sessions and resolve once their characters were saved (so the erasure comes after). */
/** Close the subjects' game sessions and resolve once their characters were saved (so the erasure comes after). */
closeSessions: (subjects: string[]) => Promise<void>
closeSessions: (subjects: string[]) => Promise<void>
log: Logger
log: Logger
}): { apply(event: AccountEvent): Promise<string> } {
}): { apply(event: AccountEvent): Promise<string> } {
- opts.db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- subject TEXT NOT NULL,
- outcome TEXT,
- sent_at TEXT,
- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
- )`)
- const get = opts.db.prepare<[string], Record_>(
- 'SELECT id, outcome, sent_at, applied_at FROM account_data_events WHERE id = ?',
- )
- const markSent = opts.db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?')
+ const get = (id: string): Promise<Record_ | null> =>
+ opts.db.maybe<Record_>(
+ sql`SELECT id, outcome, sent_at, applied_at FROM account_data_events WHERE id = ${id}`,
+ )
+ const markSent = (id: string): Promise<number> =>
+ opts.db.exec(sql`UPDATE account_data_events SET sent_at = now() WHERE id = ${id}`)
async function apply(event: AccountEvent): Promise<string> {
async function apply(event: AccountEvent): Promise<string> {
if (!event || !ACCOUNT_TOPICS.includes(event.event_type as (typeof ACCOUNT_TOPICS)[number]))
if (!event || !ACCOUNT_TOPICS.includes(event.event_type as (typeof ACCOUNT_TOPICS)[number]))
@@ -69,8 +63,8 @@ export function createAccountData(opts: {
@@ -69,8 +63,8 @@ export function createAccountData(opts: {
if (event.event_type === 'network.account.export_requested') {
if (event.event_type === 'network.account.export_requested') {
const id = String(p.export_id ?? '')
const id = String(p.export_id ?? '')
if (!EXPORT_RE.test(id)) return 'ignored:payload'
if (!EXPORT_RE.test(id)) return 'ignored:payload'
- if (get.get(id)?.sent_at) return 'unchanged'
- const data = opts.identity.exportSubject(subject)
+ if ((await get(id))?.sent_at) return 'unchanged'
+ const data = await opts.identity.exportSubject(subject)
const files = [
const files = [
{ name: 'characters.json', content: data.characters },
{ name: 'characters.json', content: data.characters },
...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),
...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),
@@ -82,22 +76,17 @@ export function createAccountData(opts: {
@@ -82,22 +76,17 @@ export function createAccountData(opts: {
? 'closed'
? 'closed'
: null
: null
if (!outcome) throw new Error(`export part refused: ${res.status}`)
if (!outcome) throw new Error(`export part refused: ${res.status}`)
- opts.db
- .prepare(
- 'INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)',
- )
- .run(
- id,
- 'export',
- subject,
- JSON.stringify({ result: outcome, characters: data.characters.length }),
- new Date().toISOString(),
- )
+ await opts.db.exec(sql`
+ INSERT INTO account_data_events (id, kind, subject, outcome, sent_at)
+ VALUES (${id}, 'export', ${subject}, ${sql.json({ result: outcome, characters: data.characters.length })}, now())
+ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject,
+ outcome = excluded.outcome, sent_at = excluded.sent_at
+ `)
return outcome
return outcome
}
}
const id = String(p.deletion_id ?? '')
const id = String(p.deletion_id ?? '')
if (!DELETION_RE.test(id)) return 'ignored:payload'
if (!DELETION_RE.test(id)) return 'ignored:payload'
- let rec = get.get(id)
+ let rec = await get(id)
let result = 'confirmed'
let result = 'confirmed'
if (!rec) {
if (!rec) {
const aliases = Array.isArray(p.aliases)
const aliases = Array.isArray(p.aliases)
@@ -105,16 +94,19 @@ export function createAccountData(opts: {
@@ -105,16 +94,19 @@ export function createAccountData(opts: {
: []
: []
const subjects = [subject, ...aliases]
const subjects = [subject, ...aliases]
await opts.closeSessions(subjects)
await opts.closeSessions(subjects)
- const erased = opts.identity.eraseSubjects(subjects)
- opts.db
- .prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)')
- .run(id, 'deletion', subject, JSON.stringify({ erased }))
+ const erased = await opts.identity.eraseSubjects(subjects)
+ await opts.db.exec(sql`
+ INSERT INTO account_data_events (id, kind, subject, outcome)
+ VALUES (${id}, 'deletion', ${subject}, ${sql.json({ erased })})
+ ON CONFLICT (id) DO NOTHING
+ `)
opts.log.info('account deleted: game data erased', { ...erased })
opts.log.info('account deleted: game data erased', { ...erased })
- rec = get.get(id) as Record_
+ rec = await get(id)
result = 'erased'
result = 'erased'
}
}
+ if (!rec) throw new Error('deletion record disappeared')
if (rec.sent_at) return 'unchanged'
if (rec.sent_at) return 'unchanged'
- const o = JSON.parse(rec.outcome ?? '{}') as { erased?: Record<string, number> }
+ const o = (rec.outcome ?? {}) as { erased?: Record<string, number> }
const erased = Object.fromEntries(
const erased = Object.fromEntries(
Object.entries(o.erased ?? {}).filter(([k]) => k !== 'structures_unowned'),
Object.entries(o.erased ?? {}).filter(([k]) => k !== 'structures_unowned'),
)
)
@@ -126,7 +118,7 @@ export function createAccountData(opts: {
@@ -126,7 +118,7 @@ export function createAccountData(opts: {
retained,
retained,
})
})
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`)
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`)
- markSent.run(new Date().toISOString(), id)
+ await markSent(id)
return result
return result
}
}
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
-import { createEventsClient, createOutbox, type SqliteDatabase } from 'openvibe-sdk/events'
+import { openTestStore } from '@openvibe/persistence/testing'
+import type { Tx } from 'openvibe-sdk/db'
+import { createEventsClient, createPgOutbox } from 'openvibe-sdk/events'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { createMockPlatform } from 'openvibe-sdk/testing'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { loadPlatformConfig } from '../config.js'
import { loadPlatformConfig } from '../config.js'
@@ -28,13 +29,17 @@ const contracts = createRequire(import.meta.url)('openvibe-contracts') as Contra
@@ -28,13 +29,17 @@ const contracts = createRequire(import.meta.url)('openvibe-contracts') as Contra
const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
const signedIn = { playerId: 'pl_1', slot: 0, name: 'Ana', subjectId: SUBJECT }
const signedIn = { playerId: 'pl_1', slot: 0, name: 'Ana', subjectId: SUBJECT }
const guest = { playerId: 'pl_2', slot: 0, name: 'Guest', subjectId: null }
const guest = { playerId: 'pl_2', slot: 0, name: 'Guest', subjectId: null }
+/** The recorder only forwards the transaction handle to the sink; a capture sink ignores it. */
+const ANY_TX = {} as Tx
function capture(): EventSink & { events: { event_type: string; payload: unknown }[] } {
function capture(): EventSink & { events: { event_type: string; payload: unknown }[] } {
const events: { event_type: string; payload: unknown }[] = []
const events: { event_type: string; payload: unknown }[] = []
return {
return {
enabled: true,
enabled: true,
events,
events,
- enqueue: (e) => events.push(e as { event_type: string; payload: unknown }),
+ enqueue: async (_t, e) => {
+ events.push(e as { event_type: string; payload: unknown })
+ },
}
}
}
}
@@ -116,18 +121,21 @@ describe('event envelopes', () => {
@@ -116,18 +121,21 @@ describe('event envelopes', () => {
})
})
describe('GameEventRecorder', () => {
describe('GameEventRecorder', () => {
- it('diffs each save against the last persisted progress', () => {
+ it('diffs each save against the last persisted progress', async () => {
const sink = capture()
const sink = capture()
const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
- rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
- rec.recordPlayersSaved([
+ await rec.recordJoin(ANY_TX, signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
+ let commit = await rec.recordPlayersSaved(ANY_TX, [
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
- ])()
+ ])
+ commit()
// Saved again with no change: nothing new.
// Saved again with no change: nothing new.
- rec.recordPlayersSaved([
+ commit = await rec.recordPlayersSaved(ANY_TX, [
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
- ])()
- rec.recordLeave(signedIn)()
+ ])
+ commit()
+ commit = await rec.recordLeave(ANY_TX, signedIn)
+ commit()
expect(sink.events.map((e) => e.event_type)).toEqual([
expect(sink.events.map((e) => e.event_type)).toEqual([
'games.player.joined',
'games.player.joined',
'games.skill.leveled',
'games.skill.leveled',
@@ -135,35 +143,56 @@ describe('GameEventRecorder', () => {
@@ -135,35 +143,56 @@ describe('GameEventRecorder', () => {
])
])
})
})
- it('does not advance the baseline when the save is not committed', () => {
+ it('does not advance the baseline when the save is not committed', async () => {
const sink = capture()
const sink = capture()
const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
- rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
+ await rec.recordJoin(ANY_TX, signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
// Rolled back: the commit callback never runs, so the next save reports it again.
// Rolled back: the commit callback never runs, so the next save reports it again.
- rec.recordPlayersSaved([{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } }])
- rec.recordPlayersSaved([
+ await rec.recordPlayersSaved(ANY_TX, [
+ { player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
+ ])
+ const commit = await rec.recordPlayersSaved(ANY_TX, [
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
- ])()
+ ])
+ commit()
expect(sink.events.filter((e) => e.event_type === 'games.skill.leveled')).toHaveLength(2)
expect(sink.events.filter((e) => e.event_type === 'games.skill.leveled')).toHaveLength(2)
})
})
- it('checkpoints world saves at most once per interval, always on shutdown', () => {
+ it('checkpoints world saves at most once per interval, always on shutdown', async () => {
let now = 0
let now = 0
const sink = capture()
const sink = capture()
const rec = new GameEventRecorder(sink, 'w', 60_000, () => now)
const rec = new GameEventRecorder(sink, 'w', 60_000, () => now)
now = 10_000
now = 10_000
- rec.recordWorldSaved(5, 1, 'checkpoint')()
+ let commit = await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')
+ commit()
now = 70_000
now = 70_000
- rec.recordWorldSaved(2, 0, 'checkpoint')()
+ commit = await rec.recordWorldSaved(ANY_TX, 2, 0, 'checkpoint')
+ commit()
now = 80_000
now = 80_000
- rec.recordWorldSaved(1, 1, 'checkpoint')()
- rec.recordWorldSaved(0, 0, 'shutdown')()
+ commit = await rec.recordWorldSaved(ANY_TX, 1, 1, 'checkpoint')
+ commit()
+ commit = await rec.recordWorldSaved(ANY_TX, 0, 0, 'shutdown')
+ commit()
const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')
const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')
expect(saved.map((e) => e.payload)).toEqual([
expect(saved.map((e) => e.payload)).toEqual([
expect.objectContaining({ reason: 'checkpoint', entities_written: 7, players_written: 1 }),
expect.objectContaining({ reason: 'checkpoint', entities_written: 7, players_written: 1 }),
expect.objectContaining({ reason: 'shutdown', entities_written: 1, players_written: 1 }),
expect.objectContaining({ reason: 'shutdown', entities_written: 1, players_written: 1 }),
])
])
})
})
+
+ it('counts a rolled-back world save once when the flush is retried', async () => {
+ const sink = capture()
+ const rec = new GameEventRecorder(sink, 'w', 0, () => 1)
+ // First attempt: the event is queued but its transaction rolls back, so the commit callback never
+ // runs. The retry of the same flush must not add its rows to the accumulator a second time.
+ await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')
+ const commit = await rec.recordWorldSaved(ANY_TX, 5, 1, 'checkpoint')
+ commit()
+ const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')
+ expect(saved.at(-1)?.payload).toEqual(
+ expect.objectContaining({ entities_written: 5, players_written: 1 }),
+ )
+ })
})
})
describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
@@ -183,40 +212,35 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
@@ -183,40 +212,35 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
eventsUrl: platform.origins.events,
eventsUrl: platform.origins.events,
}
}
const pc = createPlatformClient(cfg, platform.fetch)!
const pc = createPlatformClient(cfg, platform.fetch)!
- const store = openSqliteStore(':memory:')
- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
+ const store = await openTestStore()
+ const outbox = createPgOutbox(store.db, {
events: createEventsClient(pc.client, { source: EVENT_SOURCE }),
events: createEventsClient(pc.client, { source: EVENT_SOURCE }),
})
})
- outbox.ensureSchema()
- const sink = outboxSink(outbox)
+ const sink = outboxSink(outbox, store.db)
// Committed with the change it describes.
// Committed with the change it describes.
- store.transaction(() => {
- store.meta.set('env_time', '0.5')
- sink.enqueue(playerJoinedEvent(signedIn, { world: 'w', restored: false }))
+ await store.transaction(async (t) => {
+ await store.meta.set('env_time', 0.5)
+ await sink.enqueue(t, playerJoinedEvent(signedIn, { world: 'w', restored: false }))
})
})
// Rolled back: the event never existed.
// Rolled back: the event never existed.
- expect(() =>
- store.transaction(() => {
- store.meta.set('env_time', '0.9')
- sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 }))
+ await expect(
+ store.transaction(async (t) => {
+ await store.meta.set('env_time', 0.9)
+ await sink.enqueue(t, playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 }))
throw new Error('rollback')
throw new Error('rollback')
}),
}),
- ).toThrow('rollback')
- // Outside a transaction the outbox refuses.
- expect(() =>
- sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 })),
- ).toThrow()
+ ).rejects.toThrow('rollback')
- expect(outbox.pending()).toBe(1)
+ expect(await outbox.pending()).toBe(1)
const stats = await outbox.flush()
const stats = await outbox.flush()
expect(stats).toEqual({ sent: 1, failed: 0, rejected: 0 })
expect(stats).toEqual({ sent: 1, failed: 0, rejected: 0 })
expect(platform.state.events.map((e) => [e.event.event_type, e.publisher])).toEqual([
expect(platform.state.events.map((e) => [e.event.event_type, e.publisher])).toEqual([
['games.player.joined', 'svc:games'],
['games.player.joined', 'svc:games'],
])
])
- expect(store.meta.get('env_time')).toBe('0.5')
+ expect(await store.meta.get('env_time')).toBe(0.5)
await outbox.stop()
await outbox.stop()
- store.close()
+ await store.close()
})
})
it('keeps events queued while the grant is missing', async () => {
it('keeps events queued while the grant is missing', async () => {
@@ -227,22 +251,45 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
@@ -227,22 +251,45 @@ describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
networkUrl: platform.origins.network,
networkUrl: platform.origins.network,
eventsUrl: platform.origins.events,
eventsUrl: platform.origins.events,
}
}
- const store = openSqliteStore(':memory:')
- const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
+ const store = await openTestStore()
+ const outbox = createPgOutbox(store.db, {
events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {
events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {
source: EVENT_SOURCE,
source: EVENT_SOURCE,
}),
}),
})
})
- outbox.ensureSchema()
- store.transaction(() =>
- outboxSink(outbox).enqueue(
+ await store.transaction((t) =>
+ outboxSink(outbox, store.db).enqueue(
+ t,
worldSavedEvent('w', { reason: 'shutdown', entities: 0, players: 0, sinceMs: 0 }),
worldSavedEvent('w', { reason: 'shutdown', entities: 0, players: 0, sinceMs: 0 }),
),
),
)
)
const stats = await outbox.flush()
const stats = await outbox.flush()
expect(stats.sent).toBe(0)
expect(stats.sent).toBe(0)
- expect(outbox.pending() + outbox.rejected()).toBe(1)
+ expect(((await outbox.pending()) ?? 0) + ((await outbox.rejected()) ?? 0)).toBe(1)
+ await outbox.stop()
+ await store.close()
+ })
+
+ it('refuses to enqueue outside a transaction: an event never commits on its own connection', async () => {
+ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })
+ const cfg = {
+ ...loadPlatformConfig({}),
+ clientSecret: 's3cret',
+ networkUrl: platform.origins.network,
+ eventsUrl: platform.origins.events,
+ }
+ const store = await openTestStore()
+ const outbox = createPgOutbox(store.db, {
+ events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {
+ source: EVENT_SOURCE,
+ }),
+ })
+ const sink = outboxSink(outbox, store.db)
+ await expect(
+ sink.enqueue({} as Tx, playerJoinedEvent(signedIn, { world: 'w', restored: false })),
+ ).rejects.toThrow('must run inside the transaction')
+ expect(await outbox.pending()).toBe(0)
await outbox.stop()
await outbox.stop()
- store.close()
+ await store.close()
})
})
})
})
@@ -23,7 +23,8 @@
@@ -23,7 +23,8 @@
* events.event.publish). Events or Network being down never blocks a tick:
* events.event.publish). Events or Network being down never blocks a tick:
* rows wait and are retried.
* rows wait and are retried.
*/
*/
-import type { EventInput, Outbox } from 'openvibe-sdk/events'
+import type { EventInput, PgOutbox } from 'openvibe-sdk/events'
+import type { Db, Tx } from 'openvibe-sdk/db'
import type { SubjectRef } from 'openvibe-sdk/core'
import type { SubjectRef } from 'openvibe-sdk/core'
export const EVENT_SOURCE = 'games'
export const EVENT_SOURCE = 'games'
@@ -210,20 +211,30 @@ export function moderationEvent(
@@ -210,20 +211,30 @@ export function moderationEvent(
}
}
}
}
-/** Where events go. `enqueue` must run inside the transaction making the change. */
+/**
+ * Where events go. `enqueue` is awaited inside the transaction making the change and is handed that
+ * transaction's handle: the SDK's PostgreSQL outbox writes the row on it, so the event exists if and
+ * only if the change commits. Only the relay wake is deferred.
+ */
export interface EventSink {
export interface EventSink {
readonly enabled: boolean
readonly enabled: boolean
- enqueue(event: EventInput): void
+ enqueue(t: Tx, event: EventInput): Promise<void>
}
}
-export const NO_EVENTS: EventSink = { enabled: false, enqueue: () => {} }
+export const NO_EVENTS: EventSink = { enabled: false, enqueue: async () => {} }
-/** An EventSink over the SDK outbox; wakes the relay right after the write. */
-export function outboxSink(outbox: Outbox): EventSink {
+/** An EventSink over the SDK PostgreSQL outbox; wakes the relay right after the write. */
+export function outboxSink(outbox: PgOutbox, db: Db): EventSink {
return {
return {
enabled: true,
enabled: true,
- enqueue(event) {
- outbox.enqueue(event)
+ async enqueue(t, event) {
+ // The old SQLite outbox refused an enqueue outside a transaction; the PG port takes the tx handle
+ // but a Db also satisfies the type, so nothing else would stop an event committing on its own
+ // connection. Refuse it here too.
+ if (!db.inTransaction()) {
+ throw new Error('outbox.enqueue() must run inside the transaction that makes the change')
+ }
+ await outbox.enqueue(t, event)
setImmediate(() => outbox.kick())
setImmediate(() => outbox.kick())
},
},
}
}
@@ -238,7 +249,7 @@ interface Tracked {
@@ -238,7 +249,7 @@ interface Tracked {
/**
/**
* Per-session bookkeeping the game server drives: the progression baseline
* Per-session bookkeeping the game server drives: the progression baseline
* each save is compared with, join times, and the world-save accumulator.
* each save is compared with, join times, and the world-save accumulator.
- * All `record*` methods run inside the caller's store transaction and return
+ * All `record*` methods are handed the caller's transaction handle and return
* a callback to run once it has committed.
* a callback to run once it has committed.
*/
*/
export class GameEventRecorder {
export class GameEventRecorder {
@@ -263,43 +274,51 @@ export class GameEventRecorder {
@@ -263,43 +274,51 @@ export class GameEventRecorder {
}
}
/** A character joined; `progress` is what the database holds for it. */
/** A character joined; `progress` is what the database holds for it. */
- recordJoin(player: EventPlayer, progress: ProgressSnapshot, restored: boolean): void {
+ async recordJoin(
+ t: Tx,
+ player: EventPlayer,
+ progress: ProgressSnapshot,
+ restored: boolean,
+ ): Promise<void> {
this.tracked.set(player.playerId, {
this.tracked.set(player.playerId, {
player,
player,
progress: cloneProgress(progress),
progress: cloneProgress(progress),
joinedAt: this.now(),
joinedAt: this.now(),
})
})
- this.sink.enqueue(playerJoinedEvent(player, { world: this.world, restored }))
+ await this.sink.enqueue(t, playerJoinedEvent(player, { world: this.world, restored }))
}
}
/**
/**
* Players were written: emits the progression they newly persisted.
* Players were written: emits the progression they newly persisted.
* Returns the commit callback that advances their baselines.
* Returns the commit callback that advances their baselines.
*/
*/
- recordPlayersSaved(saved: { player: EventPlayer; progress: ProgressSnapshot }[]): () => void {
+ async recordPlayersSaved(
+ t: Tx,
+ saved: { player: EventPlayer; progress: ProgressSnapshot }[],
+ ): Promise<() => void> {
const advance: { id: string; progress: ProgressSnapshot }[] = []
const advance: { id: string; progress: ProgressSnapshot }[] = []
for (const { player, progress } of saved) {
for (const { player, progress } of saved) {
- const t = this.tracked.get(player.playerId)
- if (t) {
- for (const ev of progressEvents(player, t.progress, progress, this.world)) {
- this.sink.enqueue(ev)
+ const tr = this.tracked.get(player.playerId)
+ if (tr) {
+ for (const ev of progressEvents(player, tr.progress, progress, this.world)) {
+ await this.sink.enqueue(t, ev)
}
}
}
}
advance.push({ id: player.playerId, progress: cloneProgress(progress) })
advance.push({ id: player.playerId, progress: cloneProgress(progress) })
}
}
return () => {
return () => {
for (const a of advance) {
for (const a of advance) {
- const t = this.tracked.get(a.id)
- if (t) t.progress = a.progress
+ const tr = this.tracked.get(a.id)
+ if (tr) tr.progress = a.progress
}
}
}
}
}
}
/** The character left; its final save is part of the same transaction. */
/** The character left; its final save is part of the same transaction. */
- recordLeave(player: EventPlayer): () => void {
- const t = this.tracked.get(player.playerId)
- const sessionSeconds = t ? (this.now() - t.joinedAt) / 1000 : 0
- this.sink.enqueue(playerLeftEvent(player, { world: this.world, sessionSeconds }))
+ async recordLeave(t: Tx, player: EventPlayer): Promise<() => void> {
+ const tr = this.tracked.get(player.playerId)
+ const sessionSeconds = tr ? (this.now() - tr.joinedAt) / 1000 : 0
+ await this.sink.enqueue(t, playerLeftEvent(player, { world: this.world, sessionSeconds }))
return () => {
return () => {
this.tracked.delete(player.playerId)
this.tracked.delete(player.playerId)
}
}
@@ -310,24 +329,32 @@ export class GameEventRecorder {
@@ -310,24 +329,32 @@ export class GameEventRecorder {
* `games.world.saved` checkpoint at most once per interval (and always on
* `games.world.saved` checkpoint at most once per interval (and always on
* shutdown) with the totals since the previous one.
* shutdown) with the totals since the previous one.
*/
*/
- recordWorldSaved(
+ async recordWorldSaved(
+ t: Tx,
entities: number,
entities: number,
players: number,
players: number,
reason: 'checkpoint' | 'shutdown',
reason: 'checkpoint' | 'shutdown',
- ): () => void {
- this.pendingEntities += entities
- this.pendingPlayers += players
+ ): Promise<() => void> {
const nowMs = this.now()
const nowMs = this.now()
+ // The accumulator holds only committed saves. This flush's rows join it at commit, never before,
+ // so a failed (and retried) save is counted once and a rollback leaves nothing behind.
+ const totalEntities = this.pendingEntities + entities
+ const totalPlayers = this.pendingPlayers + players
const due =
const due =
reason === 'shutdown' ||
reason === 'shutdown' ||
- (nowMs - this.lastSavedEventMs >= this.checkpointEveryMs &&
- this.pendingEntities + this.pendingPlayers > 0)
- if (!due) return () => {}
- this.sink.enqueue(
+ (nowMs - this.lastSavedEventMs >= this.checkpointEveryMs && totalEntities + totalPlayers > 0)
+ if (!due) {
+ return () => {
+ this.pendingEntities = totalEntities
+ this.pendingPlayers = totalPlayers
+ }
+ }
+ await this.sink.enqueue(
+ t,
worldSavedEvent(this.world, {
worldSavedEvent(this.world, {
reason,
reason,
- entities: this.pendingEntities,
- players: this.pendingPlayers,
+ entities: totalEntities,
+ players: totalPlayers,
sinceMs: this.sinceMs,
sinceMs: this.sinceMs,
}),
}),
)
)
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
-import { openSqliteStore } from '@openvibe/persistence/sqlite'
+import type { PersistenceStore } from '@openvibe/persistence'
+import { openTestStore } from '@openvibe/persistence/testing'
+import type { Logger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
+import type { OpenVibeClient } from 'openvibe-sdk/core'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { loadPlatformConfig } from '../config.js'
import { loadPlatformConfig } from '../config.js'
import { MediaMirror } from './mediaMirror.js'
import { MediaMirror } from './mediaMirror.js'
@@ -94,14 +97,14 @@ function fakePlatform(opts: { failPuts?: number; completeStatus?: number } = {})
@@ -94,14 +97,14 @@ function fakePlatform(opts: { failPuts?: number; completeStatus?: number } = {})
return { fetchImpl, objects, calls }
return { fetchImpl, objects, calls }
}
}
-function setup(platform: ReturnType<typeof fakePlatform>) {
+async function setup(platform: ReturnType<typeof fakePlatform>) {
const dir = mkdtempSync(join(tmpdir(), 'openvibe-mirror-'))
const dir = mkdtempSync(join(tmpdir(), 'openvibe-mirror-'))
const assetsDir = join(dir, 'map-assets')
const assetsDir = join(dir, 'map-assets')
mkdirSync(assetsDir)
mkdirSync(assetsDir)
const hash = `sha256-${createHash('sha256').update(PNG).digest('hex')}`
const hash = `sha256-${createHash('sha256').update(PNG).digest('hex')}`
const fileName = `${hash}.png`
const fileName = `${hash}.png`
writeFileSync(join(assetsDir, fileName), PNG)
writeFileSync(join(assetsDir, fileName), PNG)
- const store = openSqliteStore(':memory:')
+ const store = await openTestStore()
let now = 1_000
let now = 1_000
const pc = createPlatformClient(
const pc = createPlatformClient(
{
{
@@ -129,14 +132,30 @@ function setup(platform: ReturnType<typeof fakePlatform>) {
@@ -129,14 +132,30 @@ function setup(platform: ReturnType<typeof fakePlatform>) {
}
}
}
}
+/**
+ * MediaMirror.enqueue is fire-and-forget (void); poll until the queue row it writes has landed so a
+ * following runOnce is deterministic.
+ */
+async function queued(
+ store: Awaited<ReturnType<typeof openTestStore>>,
+ hash: string,
+): Promise<void> {
+ for (let i = 0; i < 500; i++) {
+ if (await store.mediaMirrors.get(hash)) return
+ await new Promise((resolve) => setTimeout(resolve, 2))
+ }
+ throw new Error(`asset ${hash} was not queued`)
+}
+
describe('map assets → Media objects (games service token)', () => {
describe('map assets → Media objects (games service token)', () => {
it('mirrors a stored asset with init, content and a hash-verified complete', async () => {
it('mirrors a stored asset with init, content and a hash-verified complete', async () => {
const platform = fakePlatform()
const platform = fakePlatform()
- const { store, mirror, hash, asset } = setup(platform)
+ const { store, mirror, hash, asset } = await setup(platform)
mirror.enqueue(asset)
mirror.enqueue(asset)
+ await queued(store, hash)
mirror.stop()
mirror.stop()
await mirror.runOnce()
await mirror.runOnce()
- const row = store.mediaMirrors.get(hash)!
+ const row = (await store.mediaMirrors.get(hash))!
expect(row).toMatchObject({ status: 'mirrored', attempts: 1 })
expect(row).toMatchObject({ status: 'mirrored', attempts: 1 })
expect(row.mediaId).toMatch(/^med_/)
expect(row.mediaId).toMatch(/^med_/)
expect(row.publicUrl).toBe(`https://openvibe.media/o/${row.mediaId}`)
expect(row.publicUrl).toBe(`https://openvibe.media/o/${row.mediaId}`)
@@ -151,27 +170,28 @@ describe('map assets → Media objects (games service token)', () => {
@@ -151,27 +170,28 @@ describe('map assets → Media objects (games service token)', () => {
it('backfills assets already on disk', async () => {
it('backfills assets already on disk', async () => {
const platform = fakePlatform()
const platform = fakePlatform()
- const { store, mirror, hash } = setup(platform)
+ const { store, mirror, hash } = await setup(platform)
expect(await mirror.backfill()).toBe(1)
expect(await mirror.backfill()).toBe(1)
expect(await mirror.backfill()).toBe(0)
expect(await mirror.backfill()).toBe(0)
await mirror.runOnce()
await mirror.runOnce()
- expect(store.mediaMirrors.get(hash)?.status).toBe('mirrored')
+ expect((await store.mediaMirrors.get(hash))?.status).toBe('mirrored')
})
})
it('resumes the same object after a failure instead of creating another', async () => {
it('resumes the same object after a failure instead of creating another', async () => {
const platform = fakePlatform({ failPuts: 1 })
const platform = fakePlatform({ failPuts: 1 })
- const { store, mirror, hash, asset, advance } = setup(platform)
+ const { store, mirror, hash, asset, advance } = await setup(platform)
mirror.enqueue(asset)
mirror.enqueue(asset)
+ await queued(store, hash)
mirror.stop()
mirror.stop()
await mirror.runOnce()
await mirror.runOnce()
- const failed = store.mediaMirrors.get(hash)!
+ const failed = (await store.mediaMirrors.get(hash))!
expect(failed).toMatchObject({ status: 'pending', attempts: 1 })
expect(failed).toMatchObject({ status: 'pending', attempts: 1 })
expect(failed.lastError).toMatch(/503|unavailable/)
expect(failed.lastError).toMatch(/503|unavailable/)
await mirror.runOnce() // not due yet
await mirror.runOnce() // not due yet
- expect(store.mediaMirrors.get(hash)?.attempts).toBe(1)
+ expect((await store.mediaMirrors.get(hash))?.attempts).toBe(1)
advance(60_000)
advance(60_000)
await mirror.runOnce()
await mirror.runOnce()
- expect(store.mediaMirrors.get(hash)).toMatchObject({
+ expect(await store.mediaMirrors.get(hash)).toMatchObject({
status: 'mirrored',
status: 'mirrored',
mediaId: failed.mediaId,
mediaId: failed.mediaId,
})
})
@@ -180,10 +200,64 @@ describe('map assets → Media objects (games service token)', () => {
@@ -180,10 +200,64 @@ describe('map assets → Media objects (games service token)', () => {
it('gives up on refusals retrying cannot fix', async () => {
it('gives up on refusals retrying cannot fix', async () => {
const platform = fakePlatform({ completeStatus: 422 })
const platform = fakePlatform({ completeStatus: 422 })
- const { store, mirror, hash, asset } = setup(platform)
+ const { store, mirror, hash, asset } = await setup(platform)
mirror.enqueue(asset)
mirror.enqueue(asset)
+ await queued(store, hash)
mirror.stop()
mirror.stop()
await mirror.runOnce()
await mirror.runOnce()
- expect(store.mediaMirrors.get(hash)?.status).toBe('failed')
+ expect((await store.mediaMirrors.get(hash))?.status).toBe('failed')
+ })
+
+ it('a scheduled pass whose failure write rejects is logged, not an unhandled rejection', async () => {
+ // A store whose only due row cannot be read locally, and whose markFailed write then rejects:
+ // pass() rejects (fail() awaits markFailed too).
+ const row = {
+ assetHash: `sha256-${'a'.repeat(64)}`,
+ fileName: `sha256-${'a'.repeat(64)}.png`,
+ mime: 'image/png',
+ attempts: 0,
+ }
+ const store = {
+ mediaMirrors: {
+ due: async () => [row],
+ markFailed: async () => {
+ throw new Error('persistence down')
+ },
+ },
+ } as unknown as PersistenceStore
+ const warnings: string[] = []
+ const quiet = {
+ debug: () => {},
+ info: () => {},
+ warn: (message: string) => warnings.push(message),
+ error: () => {},
+ } as unknown as Logger
+ const mirror = new MediaMirror({
+ client: {} as OpenVibeClient,
+ store,
+ namespace: 'games',
+ assetsDir: mkdtempSync(join(tmpdir(), 'openvibe-mirror-fail-')),
+ log: quiet,
+ intervalMs: 60_000,
+ })
+ // Direct callers still see the rejection: runOnce's contract is unchanged.
+ await expect(mirror.runOnce()).rejects.toThrow('persistence down')
+
+ // The scheduled path must swallow it (a warning), or the process sees an unhandled rejection.
+ const unhandled: unknown[] = []
+ const onUnhandled = (err: unknown) => unhandled.push(err)
+ process.on('unhandledRejection', onUnhandled)
+ try {
+ mirror.kick()
+ for (let i = 0; i < 200 && !warnings.includes('media mirror pass failed'); i++) {
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ } finally {
+ process.off('unhandledRejection', onUnhandled)
+ await mirror.stop()
+ }
+ expect(warnings).toContain('media mirror pass failed')
+ expect(unhandled).toEqual([])
})
})
})
})
@@ -69,15 +69,21 @@ export class MediaMirror {
@@ -69,15 +69,21 @@ export class MediaMirror {
this.now = opts.now ?? Date.now
this.now = opts.now ?? Date.now
}
}
- /** Queues one stored asset (idempotent) and wakes the worker. */
+ /** Queues one stored asset (idempotent) and wakes the worker. Fire-and-forget (off the tick). */
enqueue(asset: { hash: string; url: string; bytes: number; mime: string }): void {
enqueue(asset: { hash: string; url: string; bytes: number; mime: string }): void {
const fileName = asset.url.split('/').pop() ?? ''
const fileName = asset.url.split('/').pop() ?? ''
if (!CONTENT_ADDRESSED.test(fileName)) return
if (!CONTENT_ADDRESSED.test(fileName)) return
- const added = this.opts.store.mediaMirrors.enqueue(
- { assetHash: asset.hash, fileName, mime: asset.mime, bytes: asset.bytes },
- this.now(),
- )
- if (added) this.kick()
+ void this.opts.store.mediaMirrors
+ .enqueue(
+ { assetHash: asset.hash, fileName, mime: asset.mime, bytes: asset.bytes },
+ this.now(),
+ )
+ .then((added) => {
+ if (added) this.kick()
+ })
+ .catch((err: unknown) => {
+ this.opts.log.warn('media mirror enqueue failed', { error: String(err) })
+ })
}
}
/** Queues every content-addressed asset already on disk (a no-op for known ones). */
/** Queues every content-addressed asset already on disk (a no-op for known ones). */
@@ -93,7 +99,7 @@ export class MediaMirror {
@@ -93,7 +99,7 @@ export class MediaMirror {
const m = CONTENT_ADDRESSED.exec(name)
const m = CONTENT_ADDRESSED.exec(name)
if (!m) continue
if (!m) continue
const bytes = (await readFile(join(this.opts.assetsDir, name))).length
const bytes = (await readFile(join(this.opts.assetsDir, name))).length
- const queued = this.opts.store.mediaMirrors.enqueue(
+ const queued = await this.opts.store.mediaMirrors.enqueue(
{ assetHash: m[1] ?? '', fileName: name, mime: MIME_BY_EXT[m[2] ?? ''] ?? '', bytes },
{ assetHash: m[1] ?? '', fileName: name, mime: MIME_BY_EXT[m[2] ?? ''] ?? '', bytes },
this.now(),
this.now(),
)
)
@@ -134,17 +140,23 @@ export class MediaMirror {
@@ -134,17 +140,23 @@ export class MediaMirror {
if (this.timer) clearTimeout(this.timer)
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.timer = setTimeout(() => {
this.timer = null
this.timer = null
- void this.runOnce().finally(() => this.schedule(this.opts.intervalMs ?? 60_000))
+ // The scheduled path must never surface an unhandled rejection: a rejected pass() (e.g. the
+ // markFailed write failing) is logged, then the next pass is scheduled as usual.
+ void this.runOnce()
+ .finally(() => this.schedule(this.opts.intervalMs ?? 60_000))
+ .catch((err: unknown) => {
+ this.opts.log.warn('media mirror pass failed', { error: String(err) })
+ })
}, ms)
}, ms)
this.timer.unref()
this.timer.unref()
}
}
private async pass(): Promise<void> {
private async pass(): Promise<void> {
- for (const row of this.opts.store.mediaMirrors.due(this.now(), 20)) {
+ for (const row of await this.opts.store.mediaMirrors.due(this.now(), 20)) {
try {
try {
await this.mirror(row)
await this.mirror(row)
} catch (err) {
} catch (err) {
- this.fail(row, err as HttpError)
+ await this.fail(row, err as HttpError)
}
}
}
}
}
}
@@ -159,7 +171,7 @@ export class MediaMirror {
@@ -159,7 +171,7 @@ export class MediaMirror {
try {
try {
bytes = await readFile(join(this.opts.assetsDir, row.fileName))
bytes = await readFile(join(this.opts.assetsDir, row.fileName))
} catch {
} catch {
- store.mediaMirrors.markFailed(
+ await store.mediaMirrors.markFailed(
row.assetHash,
row.assetHash,
'local file missing',
'local file missing',
this.now(),
this.now(),
@@ -187,7 +199,7 @@ export class MediaMirror {
@@ -187,7 +199,7 @@ export class MediaMirror {
idempotencyKey: false,
idempotencyKey: false,
})
})
id = init.id
id = init.id
- store.mediaMirrors.noteObject(row.assetHash, id, this.now())
+ await store.mediaMirrors.noteObject(row.assetHash, id, this.now())
}
}
try {
try {
await client.request({
await client.request({
@@ -209,17 +221,22 @@ export class MediaMirror {
@@ -209,17 +221,22 @@ export class MediaMirror {
json: { content_hash: sha256 },
json: { content_hash: sha256 },
idempotent: true,
idempotent: true,
})
})
- store.mediaMirrors.markMirrored(row.assetHash, done.id, done.public_url ?? null, this.now())
+ await store.mediaMirrors.markMirrored(
+ row.assetHash,
+ done.id,
+ done.public_url ?? null,
+ this.now(),
+ )
this.opts.log.info('map asset mirrored to media', { asset: row.assetHash, media: done.id })
this.opts.log.info('map asset mirrored to media', { asset: row.assetHash, media: done.id })
}
}
- private fail(row: MediaMirrorDto, err: HttpError): void {
+ private async fail(row: MediaMirrorDto, err: HttpError): Promise<void> {
const status = err.status ?? 0
const status = err.status ?? 0
const attempts = row.attempts + 1
const attempts = row.attempts + 1
const terminal = TERMINAL_STATUS.has(status) || attempts >= MAX_ATTEMPTS
const terminal = TERMINAL_STATUS.has(status) || attempts >= MAX_ATTEMPTS
const wait = BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)] ?? 60_000
const wait = BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)] ?? 60_000
const message = `${err.code ?? `http.${status}`}: ${err.message ?? 'failed'}`
const message = `${err.code ?? `http.${status}`}: ${err.message ?? 'failed'}`
- this.opts.store.mediaMirrors.markFailed(
+ await this.opts.store.mediaMirrors.markFailed(
row.assetHash,
row.assetHash,
message,
message,
this.now() + wait,
this.now() + wait,
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
+import { EventEmitter } from 'node:events'
import { createServer, type Server } from 'node:http'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { AddressInfo } from 'node:net'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
+import { openTestDb } from '@openvibe/persistence/testing'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
import {
@@ -16,7 +18,6 @@ import {
@@ -16,7 +18,6 @@ import {
* person's sessions are closed through onRevoked; the boot subscription is created once.
* person's sessions are closed through onRevoked; the boot subscription is created once.
*/
*/
const req = createRequire(import.meta.url)
const req = createRequire(import.meta.url)
-const Database = req('better-sqlite3') as new (path: string) => unknown
const { signDeliveryHeaders } = req('openvibe-sdk/events') as {
const { signDeliveryHeaders } = req('openvibe-sdk/events') as {
signDeliveryHeaders(body: string, secret: string): Record<string, string>
signDeliveryHeaders(body: string, secret: string): Record<string, string>
}
}
@@ -28,17 +29,17 @@ const mergedCalls: [string, string, string][] = []
@@ -28,17 +29,17 @@ const mergedCalls: [string, string, string][] = []
const accountCalls: string[] = []
const accountCalls: string[] = []
const modGrantCalls: [string, string, string][] = []
const modGrantCalls: [string, string, string][] = []
const events = createRevocationEvents({
const events = createRevocationEvents({
- db: new Database(':memory:'),
+ db: await openTestDb(),
secrets: [SECRET],
secrets: [SECRET],
onRevoked: (s, ms) => {
onRevoked: (s, ms) => {
closedFor.push([s, ms])
closedFor.push([s, ms])
return 2
return 2
},
},
- onMerged: (from, into, id) => {
+ onMerged: async (from, into, id) => {
mergedCalls.push([from, into, id])
mergedCalls.push([from, into, id])
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
},
},
- onModGrants: (p) => {
+ onModGrants: async (p) => {
modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
return 'mod:enabled'
return 'mod:enabled'
},
},
@@ -50,6 +51,7 @@ const events = createRevocationEvents({
@@ -50,6 +51,7 @@ const events = createRevocationEvents({
},
},
log,
log,
})
})
+await events.store.load()
let server: Server
let server: Server
let url: string
let url: string
@@ -149,6 +151,54 @@ describe('revocation events', () => {
@@ -149,6 +151,54 @@ describe('revocation events', () => {
])
])
})
})
+ it('a response write that throws on the success path is caught, not an unhandled rejection', async () => {
+ // A response whose first end() throws (the socket closed under us) and whose second succeeds.
+ let ends = 0
+ const res = {
+ writableEnded: false,
+ statusCode: 0,
+ writeHead(status: number) {
+ res.statusCode = status
+ return res
+ },
+ end() {
+ ends++
+ if (ends === 1) throw new Error('write after end')
+ res.writableEnded = true
+ return res
+ },
+ }
+ const body = JSON.stringify({
+ event: envelope({
+ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8',
+ event_type: 'network.account.export_requested',
+ payload: { export_id: 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2F9', subject: SUBJECT },
+ }),
+ seq: 1,
+ })
+ const req = Object.assign(new EventEmitter(), {
+ url: '/internal/events',
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) },
+ })
+ const unhandled: unknown[] = []
+ const onUnhandled = (err: unknown) => unhandled.push(err)
+ process.on('unhandledRejection', onUnhandled)
+ try {
+ if (!events.handle(req as never, res as never)) throw new Error('not handled')
+ req.emit('data', Buffer.from(body))
+ req.emit('end')
+ for (let i = 0; i < 100 && !res.writableEnded; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ } finally {
+ process.off('unhandledRejection', onUnhandled)
+ }
+ expect(unhandled).toEqual([])
+ expect(res.statusCode).toBe(500)
+ })
+
it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {
it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {
const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'
const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'
const changed = (over: Record<string, unknown> = {}) =>
const changed = (over: Record<string, unknown> = {}) =>
@@ -18,12 +18,15 @@
@@ -18,12 +18,15 @@
*/
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
+import type { Db } from 'openvibe-sdk/db'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
const req = createRequire(import.meta.url)
const req = createRequire(import.meta.url)
interface RevocationStore {
interface RevocationStore {
- apply(event: unknown): string
+ /** Reads every cutoff into memory at boot (few people have one); call before serving. */
+ load(): Promise<number>
+ apply(event: unknown): Promise<string>
isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean
isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean
cutoffFor(subject: string): number
cutoffFor(subject: string): number
}
}
@@ -50,8 +53,8 @@ type ParseDelivery = (
@@ -50,8 +53,8 @@ type ParseDelivery = (
}
}
} | null
} | null
-const { createRevocationStore } = req('openvibe-sdk/auth') as {
- createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
+const { createPgRevocationStore } = req('openvibe-sdk/auth') as {
+ createPgRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
}
}
const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
@@ -78,7 +81,7 @@ export interface RevocationEvents {
@@ -78,7 +81,7 @@ export interface RevocationEvents {
}
}
export function createRevocationEvents(opts: {
export function createRevocationEvents(opts: {
- db: unknown
+ db: Db
secrets: string[]
secrets: string[]
onRevoked: (subjectId: string, validAfterMs: number) => number
onRevoked: (subjectId: string, validAfterMs: number) => number
/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */
/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */
@@ -86,7 +89,7 @@ export function createRevocationEvents(opts: {
@@ -86,7 +89,7 @@ export function createRevocationEvents(opts: {
from: string,
from: string,
into: string,
into: string,
mergeId: string,
mergeId: string,
- ) => { moved: number; kept: number; already: boolean }
+ ) => Promise<{ moved: number; kept: number; already: boolean }>
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
onAccountEvent?: (
onAccountEvent?: (
event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
@@ -98,10 +101,10 @@ export function createRevocationEvents(opts: {
@@ -98,10 +101,10 @@ export function createRevocationEvents(opts: {
status: string
status: string
approved: string[]
approved: string[]
revision: number
revision: number
- }) => string
+ }) => Promise<string>
log: Logger
log: Logger
}): RevocationEvents {
}): RevocationEvents {
- const store = createRevocationStore(opts.db, { table: 'token_revocations' })
+ const store = createPgRevocationStore(opts.db, { table: 'token_revocations' })
const stats = { received: 0, revoked: 0, closed: 0, refused: 0, ignored: 0 }
const stats = { received: 0, revoked: 0, closed: 0, refused: 0, ignored: 0 }
const send = (res: ServerResponse, status: number, body: unknown): void => {
const send = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
@@ -133,7 +136,7 @@ export function createRevocationEvents(opts: {
@@ -133,7 +136,7 @@ export function createRevocationEvents(opts: {
if (size > MAX_BODY) req.destroy()
if (size > MAX_BODY) req.destroy()
else chunks.push(c)
else chunks.push(c)
})
})
- req.on('end', () => {
+ req.on('end', async () => {
const raw = Buffer.concat(chunks)
const raw = Buffer.concat(chunks)
let delivery: ReturnType<ParseDelivery> = null
let delivery: ReturnType<ParseDelivery> = null
for (const s of opts.secrets) {
for (const s of opts.secrets) {
@@ -147,93 +150,101 @@ export function createRevocationEvents(opts: {
@@ -147,93 +150,101 @@ export function createRevocationEvents(opts: {
}
}
stats.received++
stats.received++
const event = delivery.event
const event = delivery.event
- if (
- event.event_type === 'network.account.export_requested' ||
- event.event_type === 'network.account.deleted'
- ) {
- if (!opts.onAccountEvent) {
- stats.ignored++
- send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })
- return
- }
- opts.onAccountEvent(event).then(
- (outcome) => {
+ try {
+ if (
+ event.event_type === 'network.account.export_requested' ||
+ event.event_type === 'network.account.deleted'
+ ) {
+ if (!opts.onAccountEvent) {
+ stats.ignored++
+ send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })
+ return
+ }
+ // Awaited inside the outer try: a throw from send() (e.g. a closed socket) is caught here
+ // too, not just a rejection from onAccountEvent.
+ try {
+ const outcome = await opts.onAccountEvent(event)
if (outcome.startsWith('ignored')) stats.ignored++
if (outcome.startsWith('ignored')) stats.ignored++
send(res, 200, { event_id: event.event_id ?? null, outcome })
send(res, 200, { event_id: event.event_id ?? null, outcome })
- },
- (err: unknown) => {
+ } catch (err) {
opts.log.warn('account event failed (Events retries)', {
opts.log.warn('account event failed (Events retries)', {
type: event.event_type,
type: event.event_type,
error: String((err as Error)?.message ?? err),
error: String((err as Error)?.message ?? err),
})
})
- send(res, 500, { error: 'not applied' })
- },
- )
- return
- }
- if (event.event_type === MOD_GRANTS_TOPIC) {
- const p = (event.payload ?? {}) as {
- mod_id?: unknown
- owner?: unknown
- status?: unknown
- approved?: unknown
- revision?: unknown
+ if (!res.writableEnded) send(res, 500, { error: 'not applied' })
+ }
+ return
}
}
- let outcome: string
- if (event.source !== 'network') outcome = 'ignored:source'
- else if (!opts.onModGrants) outcome = 'ignored:no_handler'
- else if (
- typeof p.mod_id !== 'string' ||
- !Array.isArray(p.approved) ||
- typeof p.status !== 'string'
- )
- outcome = 'ignored:payload'
- else if (p.owner !== 'games') outcome = 'ignored:owner'
- else
- outcome = opts.onModGrants({
- mod_id: p.mod_id,
- owner: p.owner,
- status: p.status,
- approved: p.approved.map(String),
- revision: Number(p.revision) || 0,
- })
- if (outcome.startsWith('ignored')) stats.ignored++
- send(res, 200, { event_id: event.event_id ?? null, outcome })
- return
- }
- if (event.event_type === MERGE_TOPIC) {
- const p = event.payload ?? {}
- let outcome = 'merged'
- if (event.source !== 'network') outcome = 'ignored:source'
- else if (!opts.onMerged) outcome = 'ignored:no_handler'
- else if (
- !MERGE_RE.test(String(p.merge_id ?? '')) ||
- !SUBJECT_RE.test(String(p.from ?? '')) ||
- !SUBJECT_RE.test(String(p.into ?? '')) ||
- p.from === p.into
- )
- outcome = 'ignored:payload'
- else {
- const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))
- outcome = r.already ? 'unchanged' : 'merged'
- if (!r.already)
- opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })
+ if (event.event_type === MOD_GRANTS_TOPIC) {
+ const p = (event.payload ?? {}) as {
+ mod_id?: unknown
+ owner?: unknown
+ status?: unknown
+ approved?: unknown
+ revision?: unknown
+ }
+ let outcome: string
+ if (event.source !== 'network') outcome = 'ignored:source'
+ else if (!opts.onModGrants) outcome = 'ignored:no_handler'
+ else if (
+ typeof p.mod_id !== 'string' ||
+ !Array.isArray(p.approved) ||
+ typeof p.status !== 'string'
+ )
+ outcome = 'ignored:payload'
+ else if (p.owner !== 'games') outcome = 'ignored:owner'
+ else
+ outcome = await opts.onModGrants({
+ mod_id: p.mod_id,
+ owner: p.owner,
+ status: p.status,
+ approved: p.approved.map(String),
+ revision: Number(p.revision) || 0,
+ })
+ if (outcome.startsWith('ignored')) stats.ignored++
+ send(res, 200, { event_id: event.event_id ?? null, outcome })
+ return
}
}
- if (outcome.startsWith('ignored')) stats.ignored++
- send(res, 200, { event_id: event.event_id ?? null, outcome })
- return
+ if (event.event_type === MERGE_TOPIC) {
+ const p = event.payload ?? {}
+ let outcome = 'merged'
+ if (event.source !== 'network') outcome = 'ignored:source'
+ else if (!opts.onMerged) outcome = 'ignored:no_handler'
+ else if (
+ !MERGE_RE.test(String(p.merge_id ?? '')) ||
+ !SUBJECT_RE.test(String(p.from ?? '')) ||
+ !SUBJECT_RE.test(String(p.into ?? '')) ||
+ p.from === p.into
+ )
+ outcome = 'ignored:payload'
+ else {
+ const r = await opts.onMerged(String(p.from), String(p.into), String(p.merge_id))
+ outcome = r.already ? 'unchanged' : 'merged'
+ if (!r.already)
+ opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })
+ }
+ if (outcome.startsWith('ignored')) stats.ignored++
+ send(res, 200, { event_id: event.event_id ?? null, outcome })
+ return
+ }
+ const outcome = await store.apply(event)
+ let closed = 0
+ if (outcome === 'revoked') {
+ stats.revoked++
+ const sub = event.payload?.subject
+ const subject = (typeof sub === 'object' && sub ? sub.id : undefined) ?? ''
+ closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))
+ stats.closed += closed
+ if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
+ } else if (outcome.startsWith('ignored')) stats.ignored++
+ send(res, 200, { event_id: event.event_id ?? null, outcome, closed })
+ } catch (err) {
+ opts.log.warn('event delivery failed (Events retries)', {
+ type: event.event_type,
+ error: String((err as Error)?.message ?? err),
+ })
+ if (!res.writableEnded) send(res, 500, { error: 'not applied' })
}
}
- const outcome = store.apply(event)
- let closed = 0
- if (outcome === 'revoked') {
- stats.revoked++
- const sub = event.payload?.subject
- const subject = (typeof sub === 'object' && sub ? sub.id : undefined) ?? ''
- closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))
- stats.closed += closed
- if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
- } else if (outcome.startsWith('ignored')) stats.ignored++
- send(res, 200, { event_id: event.event_id ?? null, outcome, closed })
})
})
return true
return true
}
}
@@ -17,6 +17,8 @@ import { CLOSE_RESTART, DEADLINE_MS } from './net/gracefulStop.js'
@@ -17,6 +17,8 @@ import { CLOSE_RESTART, DEADLINE_MS } from './net/gracefulStop.js'
*/
*/
const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
+/** The real migrations, so the spawned server boots its embedded PGlite database. */
+const MIGRATIONS_DIR = join(SERVER_DIR, '..', '..', 'packages', 'persistence', 'migrations')
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
function freePort(): Promise<number> {
function freePort(): Promise<number> {
@@ -71,7 +73,11 @@ describe('SIGTERM', () => {
@@ -71,7 +73,11 @@ describe('SIGTERM', () => {
NODE_ENV: 'test',
NODE_ENV: 'test',
PORT: String(port),
PORT: String(port),
HOST: '127.0.0.1',
HOST: '127.0.0.1',
- DB_PATH: join(dir, 'world.db'),
+ // Embedded PGlite (no DATABASE_URL), migrated from the real migrations directory.
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
+ DATABASE_DIR: join(dir, 'db'),
+ GAMES_MIGRATIONS_DIR: MIGRATIONS_DIR,
MAP_PATH: join(dir, 'map.json'),
MAP_PATH: join(dir, 'map.json'),
STATIC_DIR: '',
STATIC_DIR: '',
// The platform side on, pointed at nothing: the outbox exists and has to be stopped.
// The platform side on, pointed at nothing: the outbox exists and has to be stopped.
@@ -17,7 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
@@ -17,7 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
Environment=NODE_ENV=production
Environment=NODE_ENV=production
Environment=PORT=8000
Environment=PORT=8000
Environment=HOST=127.0.0.1
Environment=HOST=127.0.0.1
-Environment=DB_PATH=/opt/openvibe.games/data/world.db
+Environment=GAMES_PLACE_ID=scraplandia
+Environment=GAMES_MIGRATIONS_DIR=/opt/openvibe.games/packages/persistence/migrations
+# DATABASE_URL (pooled), DATABASE_DIRECT_URL (owner role, for migrations) and VALKEY_URL live in the
+# env file (names only; they carry credentials).
Environment=MAP_PATH=/opt/openvibe.games/data/map.json
Environment=MAP_PATH=/opt/openvibe.games/data/map.json
Environment=STATIC_DIR=/opt/openvibe.games/apps/client/dist
Environment=STATIC_DIR=/opt/openvibe.games/apps/client/dist
Environment=SELF_URL=https://openvibe.games
Environment=SELF_URL=https://openvibe.games
@@ -25,7 +28,7 @@ Environment=PLAY_URL=https://play.openvibe.games
@@ -25,7 +28,7 @@ Environment=PLAY_URL=https://play.openvibe.games
EnvironmentFile=/etc/openvibe/games.env
EnvironmentFile=/etc/openvibe/games.env
Restart=always
Restart=always
RestartSec=3
RestartSec=3
-# The world flushes to SQLite on SIGTERM — give it time to finish.
+# The world drains its write-behind queue to PostgreSQL on SIGTERM — give it time to finish.
TimeoutStopSec=30
TimeoutStopSec=30
KillSignal=SIGTERM
KillSignal=SIGTERM
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
#
#
# ovhost does the production procedure (pull, pnpm install --frozen-lockfile, pnpm build, restart
# ovhost does the production procedure (pull, pnpm install --frozen-lockfile, pnpm build, restart
# openvibe-games) with git as the checkout owner instead of sudo git; every workspace package's
# openvibe-games) with git as the checkout owner instead of sudo git; every workspace package's
-# dependencies checked and better-sqlite3 loaded under this Node before the restart; /api/ready polled;
+# dependencies checked before the restart (the server applies any new migration at boot); /api/ready polled;
# on failure the checkout restored, reinstalled
# on failure the checkout restored, reinstalled
# and rebuilt (the build writes the served client) and the unit restarted again (exit 3). Players online are
# and rebuilt (the build writes the served client) and the unit restarted again (exit 3). Players online are
# reported and reconnect (drain policy report); --wait-idle holds the restart until nobody plays. Do not pull
# reported and reconnect (drain policy report); --wait-idle holds the restart until nobody plays. Do not pull
@@ -402,8 +402,8 @@ export const ITEMS: ItemDef[] = [
@@ -402,8 +402,8 @@ export const ITEMS: ItemDef[] = [
placeable: { maxRange: 3.5, snapStep: 0.35 },
placeable: { maxRange: 3.5, snapStep: 0.35 },
},
},
{
{
- // Id (and workstation kind) 'campfire' is persisted in world.db entity
- // defIds and player inventories — the Scraplandia rename is display-only.
+ // Id (and workstation kind) 'campfire' is persisted in entity defIds
+ // and character inventories — the Scraplandia rename is display-only.
id: 'campfire',
id: 'campfire',
name: 'Burn Barrel',
name: 'Burn Barrel',
description: 'A salvaged drum with a cook grate. Light it, cook food, stay warm.',
description: 'A salvaged drum with a cook grate. Light it, cook food, stay warm.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+-- phase: expand
+-- OpenVibe.Games on PostgreSQL 18 (ADR-0007 decision 5): the schema from scratch. No SQLite history, no
+-- data migration, no schema_version row. Text columns are COLLATE "C" so they compare the way the old
+-- SQLite keys did; epoch-millisecond timestamps are bigint (the SDK returns int8 as Number); JSON is jsonb
+-- and round-trips as objects. The event outbox and the token-revocation table are exactly the DDL
+-- openvibe-sdk/events outboxSchema() and openvibe-sdk/auth revocationSchema() expect, so the platform
+-- adapters need no ensureSchema() at boot.
+
+-- A place is a map plus its persistent state (ADR-0007 decision 4). M1 runs one instance per place; the
+-- server upserts its configured place (GAMES_PLACE_ID, default 'scraplandia') before any world write.
+CREATE TABLE places (
+ id text COLLATE "C" PRIMARY KEY,
+ name text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- Per-place key/value state: env_time, env_weather, market_<id>, world_seeded, world_id.
+CREATE TABLE world_meta (
+ place_id text NOT NULL REFERENCES places(id),
+ key text COLLATE "C" NOT NULL,
+ value jsonb NOT NULL,
+ PRIMARY KEY (place_id, key)
+);
+
+CREATE TABLE world_entities (
+ place_id text NOT NULL REFERENCES places(id),
+ id text COLLATE "C" NOT NULL,
+ kind text NOT NULL,
+ def_id text NOT NULL,
+ owner_id text,
+ pos_x double precision NOT NULL,
+ pos_y double precision NOT NULL,
+ pos_z double precision NOT NULL,
+ rot_x double precision NOT NULL,
+ rot_y double precision NOT NULL,
+ rot_z double precision NOT NULL,
+ rot_w double precision NOT NULL,
+ motion text NOT NULL,
+ state jsonb,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (place_id, id)
+);
+CREATE INDEX world_entities_kind ON world_entities (place_id, kind);
+CREATE INDEX world_entities_owner ON world_entities (owner_id) WHERE owner_id IS NOT NULL;
+
+CREATE TABLE world_constraints (
+ place_id text NOT NULL REFERENCES places(id),
+ id text COLLATE "C" NOT NULL,
+ type text NOT NULL,
+ entity_a text NOT NULL,
+ entity_b text NOT NULL,
+ params jsonb,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (place_id, id)
+);
+
+-- Characters (the old `players`). account_key is the canonical subject (usr_/gst_) for Network accounts
+-- and 'guest:' + sha256hex(guest token) for local guests; the raw guest token is never stored. A lookup by
+-- token hashes first (accountKey()).
+CREATE TABLE characters (
+ id text COLLATE "C" PRIMARY KEY,
+ account_key text COLLATE "C" NOT NULL,
+ slot integer NOT NULL,
+ subject_id text,
+ name text NOT NULL,
+ place_id text,
+ pos_x double precision NOT NULL,
+ pos_y double precision NOT NULL,
+ pos_z double precision NOT NULL,
+ yaw double precision NOT NULL,
+ inventory jsonb NOT NULL,
+ skills jsonb NOT NULL DEFAULT '{}',
+ friends jsonb NOT NULL DEFAULT '[]',
+ appearance jsonb,
+ stats jsonb,
+ armor jsonb,
+ reputation jsonb NOT NULL DEFAULT '{}',
+ unlocks jsonb NOT NULL DEFAULT '[]',
+ active_job text,
+ updated_at bigint NOT NULL
+);
+CREATE UNIQUE INDEX characters_account_slot ON characters (account_key, slot);
+CREATE INDEX characters_subject ON characters (subject_id);
+
+CREATE TABLE mods (
+ id text COLLATE "C" PRIMARY KEY,
+ name text NOT NULL,
+ version text NOT NULL,
+ target text NOT NULL,
+ runtime text NOT NULL,
+ manifest jsonb NOT NULL,
+ pack jsonb NOT NULL,
+ trust_tier text NOT NULL,
+ status text NOT NULL,
+ installed_by text NOT NULL,
+ installed_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+CREATE TABLE mod_grants (
+ mod_id text NOT NULL,
+ capability text NOT NULL,
+ granted_by text NOT NULL,
+ granted_at bigint NOT NULL,
+ revoked_at bigint,
+ revoked_by text,
+ PRIMARY KEY (mod_id, capability)
+);
+
+CREATE TABLE mod_placements (
+ mod_id text NOT NULL,
+ placement_key text NOT NULL,
+ entity_id text,
+ at bigint NOT NULL,
+ PRIMARY KEY (mod_id, placement_key)
+);
+
+CREATE TABLE mod_audit (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ mod_id text NOT NULL,
+ action text NOT NULL,
+ capability text,
+ actor text NOT NULL,
+ detail jsonb,
+ at bigint NOT NULL
+);
+CREATE INDEX mod_audit_mod ON mod_audit (mod_id, id);
+
+CREATE TABLE media_mirrors (
+ asset_hash text COLLATE "C" PRIMARY KEY,
+ file_name text NOT NULL,
+ mime text NOT NULL,
+ bytes integer NOT NULL,
+ status text NOT NULL,
+ media_id text,
+ public_url text,
+ attempts integer NOT NULL DEFAULT 0,
+ last_error text,
+ next_attempt_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX media_mirrors_due ON media_mirrors (status, next_attempt_at);
+
+-- The once-only ledger guest conversion and account merge still use (adoption_key is the guest hash or
+-- 'merge:<mergeId>'); it makes a redelivered event a no-op.
+CREATE TABLE identity_adoptions (
+ adoption_key text COLLATE "C" PRIMARY KEY,
+ subject_id text NOT NULL,
+ source text NOT NULL,
+ moved integer NOT NULL DEFAULT 0,
+ conflicts integer NOT NULL DEFAULT 0,
+ adopted_at bigint NOT NULL
+);
+
+-- Account export and deletion (ADR-033): one row per export/deletion network event, kept by the adapter.
+CREATE TABLE account_data_events (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ sent_at timestamptz,
+ applied_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- openvibe-sdk/events createPgOutbox: enqueue runs inside the caller's transaction, the relay claims due
+-- rows with a lease.
+CREATE TABLE event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
+
+-- openvibe-sdk/auth createPgRevocationStore: sign-out everywhere cutoffs, only ever moving forward.
+CREATE TABLE token_revocations (
+ subject_id text COLLATE "C" PRIMARY KEY,
+ valid_after_ms bigint NOT NULL,
+ reason text,
+ updated_at bigint NOT NULL
+);
@@ -5,18 +5,16 @@
@@ -5,18 +5,16 @@
"type": "module",
"type": "module",
"exports": {
"exports": {
".": "./src/index.ts",
".": "./src/index.ts",
- "./sqlite": "./src/sqlite/index.ts"
+ "./testing": "./src/testing.ts"
},
},
"scripts": {
"scripts": {
"build": "tsc --build"
"build": "tsc --build"
},
},
"dependencies": {
"dependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"@openvibe/gameplay": "workspace:*",
"@openvibe/gameplay": "workspace:*",
+ "@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
- "better-sqlite3": "^11.9.1",
- "@openvibe/protocol": "workspace:*"
- },
- "devDependencies": {
- "@types/better-sqlite3": "^7.6.12"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+import { createHash } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { accountKey } from './accountKey.js'
+
+describe('accountKey', () => {
+ it('passes a canonical subject through unchanged', () => {
+ expect(accountKey('usr_01J0000000000000000000000A')).toBe('usr_01J0000000000000000000000A')
+ expect(accountKey('gst_01J0000000000000000000000A')).toBe('gst_01J0000000000000000000000A')
+ })
+
+ it('hashes a guest token, never storing it', () => {
+ const token = 'guestbrowsertoken0123456789abcdef'
+ const key = accountKey(token)
+ expect(key).toBe(`guest:${createHash('sha256').update(token).digest('hex')}`)
+ expect(key).not.toContain(token)
+ })
+
+ it('is idempotent: an existing account key passes through', () => {
+ const hashed = accountKey('guestbrowsertoken0123456789abcdef')
+ expect(accountKey(hashed)).toBe(hashed)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+import { createHash } from 'node:crypto'
+
+/**
+ * The canonical account key stored in `characters.account_key` (ADR-0006).
+ *
+ * Signed-in accounts use their canonical openvibe.network subject (`usr_…`, or `gst_…` for a Network
+ * guest), which is already a key. Local guests (ADR-0004) use `guest:` + sha256hex(their browser
+ * token) — the raw token is never stored. The function is idempotent: a value that is already an
+ * account key passes through unchanged, so a DTO read back from the database can be written again
+ * without re-hashing.
+ */
+const SUBJECT = /^(usr|gst)_/
+const GUEST_KEY = /^guest:/
+
+export function accountKey(token: string): string {
+ if (SUBJECT.test(token) || GUEST_KEY.test(token)) return token
+ return `guest:${createHash('sha256').update(token).digest('hex')}`
+}
@@ -1,2 +1,5 @@
@@ -1,2 +1,5 @@
export * from './dto.js'
export * from './dto.js'
export * from './repositories.js'
export * from './repositories.js'
+export { accountKey } from './accountKey.js'
+export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/pgStore.js'
+export { MIGRATIONS_DIR } from './migrations.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+import { fileURLToPath } from 'node:url'
+
+/** The migrations directory beside this package (packages/persistence/migrations). */
+export const MIGRATIONS_DIR = fileURLToPath(new URL('../migrations', import.meta.url))
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pgStore.js'
+export { BATCH_CHUNK } from './sqlUtils.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+import { describe, expect, it } from 'vitest'
+import { sql } from 'openvibe-sdk/db'
+import { accountKey } from '../accountKey.js'
+import { MIGRATIONS_DIR, openTestDb, openTestStore } from '../testing.js'
+import { openPgStore } from './pgStore.js'
+import type { PlayerDto, WorldEntityDto } from '../dto.js'
+
+function makeEntity(id: string): WorldEntityDto {
+ return {
+ id,
+ kind: 'prop',
+ defId: 'wooden_crate',
+ ownerId: 'player1',
+ pos: [1, 2, 3],
+ rot: [0, 0, 0, 1],
+ motion: 'dynamic',
+ state: null,
+ updatedAt: 1000,
+ }
+}
+
+function makePlayer(overrides: Partial<PlayerDto> = {}): PlayerDto {
+ return {
+ id: 'p1',
+ token: 'usr_01J0000000000000000000000A',
+ name: 'Scrapper',
+ pos: [0, 1, 0],
+ yaw: 1.5,
+ armor: { defId: 'padded_jacket', count: 1, meta: { dur: 12 } },
+ reputation: { openvibeville: 12, rustjaw: -30 },
+ unlocks: ['craft_still'],
+ activeJob: { job: 'lumber_run', progress: 3 },
+ inventory: {
+ size: 24,
+ hotbar: 6,
+ slots: [{ i: 0, stack: { defId: 'wood_plank', count: 10 } }],
+ },
+ skills: { woodcutting: 120, mining: 40 },
+ friends: ['p2', 'p3'],
+ stats: { health: 90, hunger: 80, thirst: 70, stamina: 100 },
+ charSlot: 0,
+ appearance: {
+ body: 'female',
+ skin: 3,
+ hairStyle: 'bun',
+ hairColor: 2,
+ facialHair: 'none',
+ top: 1,
+ bottom: 2,
+ shoes: 3,
+ height: 1.02,
+ build: 0.95,
+ },
+ updatedAt: 2000,
+ ...overrides,
+ }
+}
+
+describe('postgres store', () => {
+ it('roundtrips world entities with batch upsert and chunking', async () => {
+ const store = await openTestStore()
+ await store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])
+ const loaded = await store.worldEntities.loadAll()
+ expect(loaded).toHaveLength(2)
+ expect(loaded.find((e) => e.id === 'a')).toEqual(makeEntity('a'))
+
+ const moved = {
+ ...makeEntity('a'),
+ pos: [9, 9, 9] as [number, number, number],
+ motion: 'frozen' as const,
+ }
+ await store.worldEntities.upsertMany([moved])
+ expect((await store.worldEntities.loadAll()).find((e) => e.id === 'a')).toEqual(moved)
+
+ // A batch over the 500-row chunk boundary.
+ const many = Array.from({ length: 601 }, (_, i) => makeEntity(`bulk-${i}`))
+ await store.worldEntities.upsertMany(many)
+ expect(await store.worldEntities.loadAll()).toHaveLength(603)
+
+ await store.worldEntities.deleteMany(['a', 'bulk-0'])
+ expect(await store.worldEntities.loadAll()).toHaveLength(601)
+ await store.close()
+ })
+
+ it('roundtrips characters by subject token including json columns', async () => {
+ const store = await openTestStore()
+ const player = makePlayer()
+ await store.players.upsert(player)
+ expect(await store.players.findByToken(player.token)).toEqual(player)
+ expect(await store.players.findByToken('nope')).toBeNull()
+ await store.close()
+ })
+
+ it('hashes a guest token: the raw token is never stored', async () => {
+ const store = await openTestStore()
+ const guest = makePlayer({ id: 'g1', token: 'guestbrowsertoken0123456789abcdef' })
+ await store.players.upsert(guest)
+ // Lookup by the raw token hashes first and finds the row.
+ const found = await store.players.findByToken(guest.token)
+ expect(found?.id).toBe('g1')
+ expect(found?.token).toBe(accountKey(guest.token))
+ expect(found?.token).not.toBe(guest.token)
+ // The raw token appears nowhere in the table.
+ const rows = await store.db.many<Record<string, unknown>>(sql`SELECT * FROM characters`)
+ expect(JSON.stringify(rows)).not.toContain(guest.token)
+ expect(rows[0]?.account_key).toBe(accountKey(guest.token))
+ await store.close()
+ })
+
+ it('roundtrips constraints with json params', async () => {
+ const store = await openTestStore()
+ await store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])
+ const params = {
+ anchorA: [0.5, 0, 0],
+ anchorB: [-0.5, 0, 0],
+ axisA: [0, 1, 0],
+ axisB: [0, 1, 0],
+ limits: { min: -1, max: 1 },
+ }
+ await store.constraints.upsertMany([
+ { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },
+ { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },
+ ])
+ expect(await store.constraints.loadAll()).toEqual([
+ { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },
+ { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },
+ ])
+ await store.constraints.deleteMany(['c1', 'c2'])
+ expect(await store.constraints.loadAll()).toEqual([])
+ await store.close()
+ })
+
+ it('persists resource state json and meta values as objects', async () => {
+ const store = await openTestStore()
+ const node: WorldEntityDto = {
+ ...makeEntity('r1'),
+ kind: 'resource',
+ defId: 'scrap_metal',
+ motion: 'static',
+ state: { remaining: 12, perUse: 2, provenance: { mapSourceId: 'n1' } },
+ }
+ await store.worldEntities.upsertMany([node])
+ expect((await store.worldEntities.loadAll())[0]?.state).toEqual({
+ remaining: 12,
+ perUse: 2,
+ provenance: { mapSourceId: 'n1' },
+ })
+ await store.meta.set('env_time', 0.34)
+ await store.meta.set('world_seeded', true)
+ await store.meta.set('market_village', { wood_plank: { stock: 4, at: 10 } })
+ expect(await store.meta.get('env_time')).toBe(0.34)
+ expect(await store.meta.get('world_seeded')).toBe(true)
+ expect(await store.meta.get('market_village')).toEqual({ wood_plank: { stock: 4, at: 10 } })
+ expect(await store.meta.get('missing')).toBeNull()
+ await store.close()
+ })
+
+ it('rolls back every write in a transaction and commits together', async () => {
+ const store = await openTestStore()
+ await expect(
+ store.transaction(async () => {
+ await store.players.upsert(makePlayer({ id: 'p1' }))
+ await store.meta.set('env_weather', 'rain')
+ throw new Error('nope')
+ }),
+ ).rejects.toThrow('nope')
+ expect(await store.players.findById('p1')).toBeNull()
+ expect(await store.meta.get('env_weather')).toBeNull()
+
+ await store.transaction(async () => {
+ await store.players.upsert(makePlayer({ id: 'p2' }))
+ await store.meta.set('env_weather', 'fog')
+ })
+ expect(await store.players.findById('p2')).not.toBeNull()
+ expect(await store.meta.get('env_weather')).toBe('fog')
+ await store.close()
+ })
+
+ it('is place-scoped: a second store on the same database sees only its own rows', async () => {
+ const db = await openTestDb()
+ const a = openPgStore(db, { placeId: 'scraplandia' })
+ const b = openPgStore(db, { placeId: 'otherplace' })
+ await a.ensurePlace()
+ await b.ensurePlace()
+ await a.worldEntities.upsertMany([makeEntity('a1')])
+ expect(await a.worldEntities.loadAll()).toHaveLength(1)
+ expect(await b.worldEntities.loadAll()).toHaveLength(0)
+ expect(MIGRATIONS_DIR).toContain('migrations')
+ expect(a.placeId).toBe('scraplandia')
+ expect(b.placeId).toBe('otherplace')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,370 @@
@@ -0,0 +1,370 @@
+import { sql, type Db, type Sql, type Tx } from 'openvibe-sdk/db'
+import type { InventoryDto, SkillsDto } from '@openvibe/gameplay'
+import type { ConstraintDto, PlayerDto, WorldEntityDto } from '../dto.js'
+import type {
+ ConstraintRepository,
+ MetaRepository,
+ PersistenceStore,
+ PlayerRepository,
+ WorldEntityRepository,
+} from '../repositories.js'
+import { accountKey } from '../accountKey.js'
+import {
+ createIdentityRepository,
+ createMediaMirrorRepository,
+ createModRepository,
+} from './platformRepositories.js'
+import { BATCH_CHUNK, chunks, idList } from './sqlUtils.js'
+
+export interface PgStoreOptions {
+ /** GAMES_PLACE_ID (default 'scraplandia'): the place every world row belongs to on this instance. */
+ placeId?: string
+}
+
+export interface PgPersistenceStore extends PersistenceStore {
+ readonly db: Db
+ readonly placeId: string
+}
+
+interface WorldEntityRow {
+ id: string
+ kind: string
+ def_id: string
+ owner_id: string | null
+ pos_x: number
+ pos_y: number
+ pos_z: number
+ rot_x: number
+ rot_y: number
+ rot_z: number
+ rot_w: number
+ motion: string
+ state: unknown
+ updated_at: number
+}
+
+interface CharacterRow {
+ id: string
+ account_key: string
+ slot: number
+ subject_id: string | null
+ name: string
+ pos_x: number
+ pos_y: number
+ pos_z: number
+ yaw: number
+ inventory: unknown
+ skills: unknown
+ friends: unknown
+ appearance: unknown
+ stats: unknown
+ armor: unknown
+ reputation: unknown
+ unlocks: unknown
+ // active_job is a text column holding the JSON of the job/progress pair (ADR-0007 job spec).
+ active_job: string | null
+ updated_at: number
+}
+
+interface ConstraintRow {
+ id: string
+ type: string
+ entity_a: string
+ entity_b: string
+ params: unknown
+ updated_at: number
+}
+
+export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStore {
+ const placeId = opts.placeId ?? process.env.GAMES_PLACE_ID ?? 'scraplandia'
+
+ const worldEntities: WorldEntityRepository = {
+ async loadAll(): Promise<WorldEntityDto[]> {
+ const rows = await db.many<WorldEntityRow>(
+ sql`SELECT id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at
+ FROM world_entities WHERE place_id = ${placeId}`,
+ )
+ return rows.map((row) => ({
+ id: row.id,
+ kind: row.kind as WorldEntityDto['kind'],
+ defId: row.def_id,
+ ownerId: row.owner_id,
+ pos: [row.pos_x, row.pos_y, row.pos_z],
+ rot: [row.rot_x, row.rot_y, row.rot_z, row.rot_w],
+ motion: row.motion as WorldEntityDto['motion'],
+ state: (row.state ?? null) as WorldEntityDto['state'],
+ updatedAt: Number(row.updated_at),
+ }))
+ },
+ async upsertMany(entities: readonly WorldEntityDto[]): Promise<void> {
+ for (const chunk of chunks(entities, BATCH_CHUNK)) {
+ const rows = chunk.map((e) => ({
+ place_id: placeId,
+ id: e.id,
+ kind: e.kind,
+ def_id: e.defId,
+ owner_id: e.ownerId,
+ pos_x: e.pos[0],
+ pos_y: e.pos[1],
+ pos_z: e.pos[2],
+ rot_x: e.rot[0],
+ rot_y: e.rot[1],
+ rot_z: e.rot[2],
+ rot_w: e.rot[3],
+ motion: e.motion,
+ state: e.state === null ? null : sql.json(e.state),
+ updated_at: e.updatedAt,
+ }))
+ await db.query(sql`
+ INSERT INTO world_entities
+ ${sql.insert(rows, [
+ 'place_id',
+ 'id',
+ 'kind',
+ 'def_id',
+ 'owner_id',
+ 'pos_x',
+ 'pos_y',
+ 'pos_z',
+ 'rot_x',
+ 'rot_y',
+ 'rot_z',
+ 'rot_w',
+ 'motion',
+ 'state',
+ 'updated_at',
+ ])}
+ ON CONFLICT (place_id, id) DO UPDATE SET
+ pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,
+ rot_x=excluded.rot_x, rot_y=excluded.rot_y, rot_z=excluded.rot_z, rot_w=excluded.rot_w,
+ motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at
+ `)
+ }
+ },
+ async deleteMany(ids: readonly string[]): Promise<void> {
+ for (const chunk of chunks(ids, BATCH_CHUNK)) {
+ if (chunk.length === 0) continue
+ await db.exec(
+ sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,
+ )
+ }
+ },
+ async deleteByKind(kind: string): Promise<void> {
+ await db.exec(sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND kind = ${kind}`)
+ },
+ }
+
+ const characterRow = (p: PlayerDto) => ({
+ id: p.id,
+ account_key: accountKey(p.token),
+ slot: p.charSlot ?? 0,
+ subject_id: p.subjectId ?? null,
+ name: p.name,
+ place_id: placeId,
+ pos_x: p.pos[0],
+ pos_y: p.pos[1],
+ pos_z: p.pos[2],
+ yaw: p.yaw,
+ inventory: sql.json(p.inventory),
+ skills: sql.json(p.skills),
+ friends: sql.json(p.friends),
+ appearance: p.appearance === null ? null : sql.json(p.appearance),
+ stats: p.stats === null ? null : sql.json(p.stats),
+ armor: p.armor === null ? null : sql.json(p.armor),
+ reputation: sql.json(p.reputation ?? {}),
+ unlocks: sql.json(p.unlocks ?? []),
+ active_job: p.activeJob === null ? null : JSON.stringify(p.activeJob),
+ updated_at: p.updatedAt,
+ })
+
+ const CHARACTER_COLUMNS = [
+ 'id',
+ 'account_key',
+ 'slot',
+ 'subject_id',
+ 'name',
+ 'place_id',
+ 'pos_x',
+ 'pos_y',
+ 'pos_z',
+ 'yaw',
+ 'inventory',
+ 'skills',
+ 'friends',
+ 'appearance',
+ 'stats',
+ 'armor',
+ 'reputation',
+ 'unlocks',
+ 'active_job',
+ 'updated_at',
+ ]
+
+ const rowToPlayer = (row: CharacterRow): PlayerDto => ({
+ id: row.id,
+ // The account key doubles as the DTO token: for a Network account it is the subject; for a guest it
+ // is `guest:<sha256>`, never the raw browser token (which is not stored).
+ token: row.account_key,
+ name: row.name,
+ pos: [row.pos_x, row.pos_y, row.pos_z],
+ yaw: row.yaw,
+ inventory: (row.inventory ?? {}) as InventoryDto,
+ skills: (row.skills ?? {}) as SkillsDto,
+ friends: (row.friends ?? []) as string[],
+ appearance: (row.appearance ?? null) as PlayerDto['appearance'],
+ armor: (row.armor ?? null) as PlayerDto['armor'],
+ reputation: (row.reputation ?? {}) as PlayerDto['reputation'],
+ unlocks: (row.unlocks ?? []) as string[],
+ activeJob:
+ row.active_job === null || row.active_job === undefined
+ ? null
+ : (JSON.parse(row.active_job) as PlayerDto['activeJob']),
+ stats: (row.stats ?? null) as PlayerDto['stats'],
+ charSlot: row.slot ?? 0,
+ ...(row.subject_id ? { subjectId: row.subject_id } : {}),
+ updatedAt: Number(row.updated_at),
+ })
+
+ const characters = sql`SELECT * FROM characters`
+ const players: PlayerRepository = {
+ async findByToken(token: string): Promise<PlayerDto | null> {
+ const row = await db.maybe<CharacterRow>(
+ sql`${characters} WHERE account_key = ${accountKey(token)}`,
+ )
+ return row ? rowToPlayer(row) : null
+ },
+ async listByToken(token: string): Promise<PlayerDto[]> {
+ const rows = await db.many<CharacterRow>(
+ sql`${characters} WHERE account_key = ${accountKey(token)} ORDER BY slot`,
+ )
+ return rows.map(rowToPlayer)
+ },
+ async findByTokenSlot(token: string, slot: number): Promise<PlayerDto | null> {
+ const row = await db.maybe<CharacterRow>(
+ sql`${characters} WHERE account_key = ${accountKey(token)} AND slot = ${slot}`,
+ )
+ return row ? rowToPlayer(row) : null
+ },
+ async findById(id: string): Promise<PlayerDto | null> {
+ const row = await db.maybe<CharacterRow>(sql`${characters} WHERE id = ${id}`)
+ return row ? rowToPlayer(row) : null
+ },
+ async upsert(player: PlayerDto): Promise<void> {
+ await db.query(upsertCharacter([player]))
+ },
+ async upsertMany(list: readonly PlayerDto[]): Promise<void> {
+ for (const chunk of chunks(list, BATCH_CHUNK)) {
+ if (chunk.length === 0) continue
+ await db.query(upsertCharacter(chunk))
+ }
+ },
+ async resetAllPositions(pos: [number, number, number], yaw: number): Promise<void> {
+ await db.exec(
+ sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw}`,
+ )
+ },
+ }
+
+ function upsertCharacter(list: readonly PlayerDto[]): Sql {
+ return sql`
+ INSERT INTO characters
+ ${sql.insert(list.map(characterRow), CHARACTER_COLUMNS)}
+ ON CONFLICT (id) DO UPDATE SET
+ subject_id = COALESCE(excluded.subject_id, characters.subject_id),
+ name = excluded.name, place_id = excluded.place_id,
+ pos_x = excluded.pos_x, pos_y = excluded.pos_y, pos_z = excluded.pos_z, yaw = excluded.yaw,
+ inventory = excluded.inventory, skills = excluded.skills, friends = excluded.friends,
+ appearance = excluded.appearance, stats = excluded.stats, armor = excluded.armor,
+ reputation = excluded.reputation, unlocks = excluded.unlocks,
+ active_job = excluded.active_job, updated_at = excluded.updated_at
+ `
+ }
+
+ const constraints: ConstraintRepository = {
+ async loadAll(): Promise<ConstraintDto[]> {
+ const rows = await db.many<ConstraintRow>(
+ sql`SELECT id, type, entity_a, entity_b, params, updated_at FROM world_constraints WHERE place_id = ${placeId}`,
+ )
+ return rows.map((row) => ({
+ id: row.id,
+ type: row.type,
+ entityA: row.entity_a,
+ entityB: row.entity_b,
+ params: (row.params ?? null) as Record<string, unknown> | null,
+ updatedAt: Number(row.updated_at),
+ }))
+ },
+ async upsertMany(list: readonly ConstraintDto[]): Promise<void> {
+ for (const chunk of chunks(list, BATCH_CHUNK)) {
+ if (chunk.length === 0) continue
+ const rows = chunk.map((c) => ({
+ place_id: placeId,
+ id: c.id,
+ type: c.type,
+ entity_a: c.entityA,
+ entity_b: c.entityB,
+ params: c.params === null ? null : sql.json(c.params),
+ updated_at: c.updatedAt,
+ }))
+ await db.query(sql`
+ INSERT INTO world_constraints
+ ${sql.insert(rows, ['place_id', 'id', 'type', 'entity_a', 'entity_b', 'params', 'updated_at'])}
+ ON CONFLICT (place_id, id) DO UPDATE SET
+ params = excluded.params, updated_at = excluded.updated_at
+ `)
+ }
+ },
+ async deleteMany(ids: readonly string[]): Promise<void> {
+ for (const chunk of chunks(ids, BATCH_CHUNK)) {
+ if (chunk.length === 0) continue
+ await db.exec(
+ sql`DELETE FROM world_constraints WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,
+ )
+ }
+ },
+ }
+
+ const meta: MetaRepository = {
+ async get(key: string): Promise<unknown | null> {
+ const value = await db.value<unknown>(
+ sql`SELECT value FROM world_meta WHERE place_id = ${placeId} AND key = ${key}`,
+ )
+ return value ?? null
+ },
+ async set(key: string, value: unknown): Promise<void> {
+ await db.exec(sql`
+ INSERT INTO world_meta (place_id, key, value) VALUES (${placeId}, ${key}, ${sql.json(value)})
+ ON CONFLICT (place_id, key) DO UPDATE SET value = excluded.value
+ `)
+ },
+ async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
+ const rows = await db.many<{ key: string; value: unknown }>(
+ sql`SELECT key, value FROM world_meta WHERE place_id = ${placeId} AND key LIKE ${`${prefix}%`} ORDER BY key`,
+ )
+ return rows.map((r) => ({ key: r.key, value: r.value }))
+ },
+ }
+
+ return {
+ db,
+ placeId,
+ async ensurePlace(name = placeId): Promise<void> {
+ await db.exec(
+ sql`INSERT INTO places (id, name) VALUES (${placeId}, ${name}) ON CONFLICT (id) DO NOTHING`,
+ )
+ },
+ worldEntities,
+ players,
+ constraints,
+ meta,
+ identity: createIdentityRepository(db),
+ mods: createModRepository(db),
+ mediaMirrors: createMediaMirrorRepository(db),
+ transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T> {
+ return db.tx(fn)
+ },
+ close(): Promise<void> {
+ return db.close()
+ },
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+import { describe, expect, it } from 'vitest'
+import { sql } from 'openvibe-sdk/db'
+import type { PlayerDto } from '../dto.js'
+import { openTestStore } from '../testing.js'
+
+const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
+const OTHER = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'
+
+function player(
+ id: string,
+ token: string,
+ slot: number,
+ extra: Partial<PlayerDto> = {},
+): PlayerDto {
+ return {
+ id,
+ token,
+ name: `char-${id}`,
+ pos: [0, 1, 0],
+ yaw: 0,
+ inventory: { size: 24, hotbar: 6, slots: [] },
+ skills: { woodcutting: 10 },
+ friends: [],
+ appearance: null,
+ charSlot: slot,
+ armor: null,
+ reputation: {},
+ unlocks: [],
+ activeJob: null,
+ stats: null,
+ updatedAt: 1,
+ ...extra,
+ }
+}
+
+describe('guest conversion (WS-B task 8)', () => {
+ it('moves the guest character into the first free slot, once, keyed by a hash of the token', async () => {
+ const store = await openTestStore()
+ await store.players.upsertMany([player('g1', 'guest00042abc', 0), player('a0', SUBJECT, 0)])
+ expect(await store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 10)).toEqual({
+ moved: 1,
+ slot: 1,
+ full: false,
+ })
+ expect(
+ (await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),
+ ).toEqual([
+ ['a0', 0],
+ ['g1', 1],
+ ])
+ expect(await store.players.listByToken('guest00042abc')).toEqual([])
+ expect(await store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 11)).toEqual({
+ moved: 0,
+ slot: null,
+ full: false,
+ })
+ const keys = (
+ await store.db.many<{ adoption_key: string }>(
+ sql`SELECT adoption_key FROM identity_adoptions`,
+ )
+ ).map((r) => r.adoption_key)
+ expect(keys.some((k) => k.includes('guest00042abc'))).toBe(false)
+ expect(keys.some((k) => /^guest:[0-9a-f]{64}$/.test(k))).toBe(true)
+ })
+
+ it('keeps the guest character when every slot is taken, and does nothing without one', async () => {
+ const store = await openTestStore()
+ await store.players.upsertMany([
+ player('a0', SUBJECT, 0),
+ player('a1', SUBJECT, 1),
+ player('a2', SUBJECT, 2),
+ player('g1', 'guestfull01', 0),
+ ])
+ expect(await store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({
+ moved: 0,
+ slot: null,
+ full: true,
+ })
+ expect((await store.players.listByToken('guestfull01')).length).toBe(1)
+ expect(await store.identity.adoptGuestCharacter('nobodyhere01', SUBJECT, 1)).toEqual({
+ moved: 0,
+ slot: null,
+ full: false,
+ })
+ })
+})
+
+describe('account merge (ADR-029)', () => {
+ const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
+ it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', async () => {
+ const store = await openTestStore()
+ // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.
+ await store.players.upsertMany([
+ player('s0', SUBJECT, 0),
+ player('f0', FROM, 0),
+ player('f1', FROM, 1),
+ player('f2', FROM, 2),
+ ])
+ expect(
+ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),
+ ).toEqual({ moved: 2, kept: 1, already: false })
+ expect(
+ (await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),
+ ).toEqual([
+ ['f1', 1],
+ ['f2', 2],
+ ['s0', 0],
+ ])
+ expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])
+ expect(
+ await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),
+ ).toEqual({ moved: 0, kept: 0, already: true })
+ expect((await store.players.listByToken(FROM)).length).toBe(1)
+ })
+})
+
+describe('account export and deletion (ADR-033)', () => {
+ const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
+ it("exports the subject's characters without the account key, then erases them and the alias's; structures stay unowned", async () => {
+ const store = await openTestStore()
+ await store.players.upsertMany([
+ player('d0', SUBJECT, 0),
+ player('d1', ALIAS, 0),
+ player('o0', OTHER, 0),
+ ])
+ for (const [id, owner] of [
+ ['e1', 'd0'],
+ ['e2', 'o0'],
+ ] as const) {
+ await store.db.exec(sql`
+ INSERT INTO world_entities (place_id, id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at)
+ VALUES (${store.placeId}, ${id}, 'structure', 'hut', ${owner}, 0, 0, 0, 0, 0, 0, 1, 'static', '{}'::jsonb, 1)
+ `)
+ }
+ await store.db.exec(sql`
+ INSERT INTO identity_adoptions (adoption_key, subject_id, source, adopted_at)
+ VALUES ('merge:mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', ${SUBJECT}, 'merge', 1)
+ `)
+ const out = await store.identity.exportSubject(SUBJECT)
+ expect(out.characters.map((c) => c.id)).toEqual(['d0'])
+ expect(out.characters[0]).not.toHaveProperty('token')
+ expect(out.characters[0]).not.toHaveProperty('account_key')
+ expect(out.characters[0]?.skills).toEqual({ woodcutting: 10 })
+ expect(out.structures.map((e) => e.id)).toEqual(['e1'])
+ expect(await store.identity.eraseSubjects([SUBJECT, ALIAS])).toEqual({
+ characters: 2,
+ structures_unowned: 1,
+ identity_rows: 1,
+ })
+ expect(await store.players.listByToken(SUBJECT)).toEqual([])
+ expect(await store.players.listByToken(ALIAS)).toEqual([])
+ expect((await store.players.listByToken(OTHER)).map((p) => p.id)).toEqual(['o0'])
+ expect(
+ await store.db.many<{ id: string; owner_id: string | null }>(
+ sql`SELECT id, owner_id FROM world_entities ORDER BY id`,
+ ),
+ ).toEqual([
+ { id: 'e1', owner_id: null },
+ { id: 'e2', owner_id: 'o0' },
+ ])
+ expect(await store.identity.eraseSubjects([SUBJECT])).toEqual({
+ characters: 0,
+ structures_unowned: 0,
+ identity_rows: 0,
+ })
+ })
+})
+
+describe('player subject_id', () => {
+ it('keeps subject_id through ordinary saves that do not carry it', async () => {
+ const store = await openTestStore()
+ await store.players.upsert(player('p1', SUBJECT, 0, { subjectId: SUBJECT }))
+ const loaded = (await store.players.findById('p1'))!
+ const { subjectId: _drop, ...withoutSubject } = loaded
+ await store.players.upsert({ ...withoutSubject, name: 'renamed' })
+ expect(await store.players.findById('p1')).toMatchObject({
+ name: 'renamed',
+ subjectId: SUBJECT,
+ })
+ })
+})
+
+describe('mod repository', () => {
+ it('stores installs, grants, placements and an append-only audit log', async () => {
+ const store = await openTestStore()
+ const id = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'
+ await store.mods.insert({
+ id,
+ name: 'Test',
+ version: '1.0.0',
+ target: 'games.browser',
+ runtime: 'games-content@1',
+ manifest: { a: 1 },
+ pack: { props: [] },
+ trustTier: 'unreviewed',
+ status: 'disabled',
+ installedBy: SUBJECT,
+ installedAt: 1,
+ updatedAt: 1,
+ })
+ await store.mods.upsertGrant({
+ modId: id,
+ capability: 'games.world.announce',
+ grantedBy: SUBJECT,
+ grantedAt: 2,
+ revokedAt: null,
+ revokedBy: null,
+ })
+ expect(await store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 3)).toBe(true)
+ expect(await store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 4)).toBe(false)
+ expect((await store.mods.grants(id))[0]).toMatchObject({ revokedAt: 3, revokedBy: SUBJECT })
+
+ await store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e1', at: 5 })
+ await store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e2', at: 6 })
+ expect(await store.mods.placements(id)).toEqual([
+ { modId: id, key: 'bench', entityId: 'e2', at: 6 },
+ ])
+ await store.mods.deletePlacement(id, 'bench')
+ expect(await store.mods.placements(id)).toEqual([])
+
+ await store.mods.audit({
+ modId: id,
+ action: 'install',
+ capability: null,
+ actor: SUBJECT,
+ detail: null,
+ at: 1,
+ })
+ await store.mods.audit({
+ modId: id,
+ action: 'grant',
+ capability: 'games.world.announce',
+ actor: SUBJECT,
+ detail: { x: 1 },
+ at: 2,
+ })
+ expect((await store.mods.auditLog(id, 10)).map((a) => a.action)).toEqual(['grant', 'install'])
+ await store.mods.setStatus(id, 'revoked', 9)
+ expect(await store.mods.get(id)).toMatchObject({
+ status: 'revoked',
+ updatedAt: 9,
+ manifest: { a: 1 },
+ })
+ })
+})
+
+describe('media mirror queue', () => {
+ it('queues once, schedules retries and records the Media object', async () => {
+ const store = await openTestStore()
+ const asset = { assetHash: 'sha256-aa', fileName: 'sha256-aa.png', mime: 'image/png', bytes: 3 }
+ expect(await store.mediaMirrors.enqueue(asset, 10)).toBe(true)
+ expect(await store.mediaMirrors.enqueue(asset, 11)).toBe(false)
+ expect(await store.mediaMirrors.due(10, 5)).toHaveLength(1)
+ await store.mediaMirrors.markFailed('sha256-aa', 'http.503: down', 100, false, 12)
+ expect(await store.mediaMirrors.due(50, 5)).toHaveLength(0)
+ expect(await store.mediaMirrors.due(100, 5)).toHaveLength(1)
+ await store.mediaMirrors.noteObject('sha256-aa', 'med_X', 13)
+ await store.mediaMirrors.markMirrored(
+ 'sha256-aa',
+ 'med_X',
+ 'https://openvibe.media/o/med_X',
+ 14,
+ )
+ expect(await store.mediaMirrors.get('sha256-aa')).toMatchObject({
+ status: 'mirrored',
+ mediaId: 'med_X',
+ attempts: 2,
+ lastError: null,
+ })
+ expect(await store.mediaMirrors.counts()).toEqual({ pending: 0, mirrored: 1, failed: 0 })
+ })
+})
+
+describe('store transactions', () => {
+ it('rolls back every repository write together', async () => {
+ const store = await openTestStore()
+ await expect(
+ store.transaction(async () => {
+ await store.players.upsert(player('p1', SUBJECT, 0))
+ await store.meta.set('env_time', '0.5')
+ throw new Error('boom')
+ }),
+ ).rejects.toThrow('boom')
+ expect(await store.players.findById('p1')).toBeNull()
+ expect(await store.meta.get('env_time')).toBeNull()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,396 @@
@@ -0,0 +1,396 @@
+import { sql, type Db } from 'openvibe-sdk/db'
+import { accountKey } from '../accountKey.js'
+import type {
+ MediaMirrorDto,
+ ModAuditDto,
+ ModGrantDto,
+ ModInstallDto,
+ ModPlacementDto,
+ ModStatus,
+ ModTrustTier,
+} from '../dto.js'
+import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
+import { idList } from './sqlUtils.js'
+
+/**
+ * PostgreSQL repositories for the platform-integration tables: the adoption ledger, the mod registry
+ * and the Media mirror queue. The game simulation never touches these; apps/server's platform
+ * adapters do.
+ */
+
+/** An exported character row without its account key (the sign-in identity). */
+function withoutAccountKey(row: Record<string, unknown>): Record<string, unknown> {
+ const { account_key: _ignored, ...rest } = row
+ return rest
+}
+
+export function createIdentityRepository(db: Db): IdentityRepository {
+ async function takenSlots(account: string): Promise<Set<number>> {
+ const rows = await db.many<{ slot: number }>(
+ sql`SELECT slot FROM characters WHERE account_key = ${account}`,
+ )
+ return new Set(rows.map((r) => r.slot))
+ }
+ async function rowsFor(account: string): Promise<{ id: string; slot: number }[]> {
+ return db.many<{ id: string; slot: number }>(
+ sql`SELECT id, slot FROM characters WHERE account_key = ${account} ORDER BY slot`,
+ )
+ }
+ const adopted = (key: string) =>
+ db.maybe<{ subject_id: string }>(
+ sql`SELECT subject_id FROM identity_adoptions WHERE adoption_key = ${key}`,
+ )
+ const moveToSlot = (id: string, account: string, subject: string, slot: number) =>
+ db.exec(
+ sql`UPDATE characters SET account_key = ${account}, subject_id = ${subject}, slot = ${slot} WHERE id = ${id}`,
+ )
+ const recordAdoption = (
+ key: string,
+ subject: string,
+ source: string,
+ moved: number,
+ conflicts: number,
+ now: number,
+ ) =>
+ db.exec(sql`
+ INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)
+ VALUES (${key}, ${subject}, ${source}, ${moved}, ${conflicts}, ${now})
+ ON CONFLICT (adoption_key) DO UPDATE SET
+ moved = identity_adoptions.moved + excluded.moved,
+ conflicts = excluded.conflicts
+ `)
+
+ return {
+ adoptGuestCharacter(guestToken, subjectId, now) {
+ return db.tx(async () => {
+ // Once per guest token, remembered by a hash of it, never the token.
+ const key = accountKey(guestToken)
+ const none = { moved: 0, slot: null, full: false }
+ if (await adopted(key)) return none
+ const rows = await rowsFor(key)
+ if (rows.length === 0) return none
+ const taken = await takenSlots(subjectId)
+ let moved = 0
+ let slot: number | null = null
+ for (const row of rows) {
+ const free = [0, 1, 2].find((s) => !taken.has(s))
+ if (free === undefined) break
+ await moveToSlot(row.id, subjectId, subjectId, free)
+ taken.add(free)
+ slot ??= free
+ moved++
+ }
+ if (moved === 0) return { moved: 0, slot: null, full: true }
+ await recordAdoption(key, subjectId, 'guest', moved, rows.length - moved, now)
+ return { moved, slot, full: false }
+ })
+ },
+
+ mergeSubject(from, into, mergeId, now) {
+ return db.tx(async () => {
+ const key = `merge:${mergeId}`
+ if (await adopted(key)) return { moved: 0, kept: 0, already: true }
+ const taken = await takenSlots(into)
+ const rows = await rowsFor(from)
+ let moved = 0
+ // First every character whose own slot is free there, then the rest into what is left.
+ const clashing: { id: string; slot: number }[] = []
+ for (const row of rows) {
+ if (taken.has(row.slot)) {
+ clashing.push(row)
+ continue
+ }
+ await moveToSlot(row.id, into, into, row.slot)
+ taken.add(row.slot)
+ moved++
+ }
+ let kept = 0
+ for (const row of clashing) {
+ const slot = [0, 1, 2].find((s) => !taken.has(s))
+ if (slot === undefined) {
+ kept++
+ continue
+ }
+ await moveToSlot(row.id, into, into, slot)
+ taken.add(slot)
+ moved++
+ }
+ await recordAdoption(key, into, 'merge', moved, kept, now)
+ return { moved, kept, already: false }
+ })
+ },
+
+ async exportSubject(subject) {
+ const characters = await db.many<Record<string, unknown>>(
+ sql`SELECT * FROM characters WHERE account_key = ${subject} OR subject_id = ${subject} ORDER BY slot`,
+ )
+ const ids = characters.map((c) => String(c.id))
+ const structures = ids.length
+ ? await db.many<Record<string, unknown>>(
+ sql`SELECT * FROM world_entities WHERE owner_id IN (${idList(ids)})`,
+ )
+ : []
+ return {
+ characters: characters.map(withoutAccountKey),
+ structures,
+ }
+ },
+
+ eraseSubjects(subjects) {
+ return db.tx(async () => {
+ const out = { characters: 0, structures_unowned: 0, identity_rows: 0 }
+ if (!subjects.length) return out
+ const ids = (
+ await db.many<{ id: string }>(
+ sql`SELECT id FROM characters WHERE account_key IN (${idList(subjects)}) OR subject_id IN (${idList(subjects)})`,
+ )
+ ).map((r) => r.id)
+ if (ids.length) {
+ out.structures_unowned = await db.exec(
+ sql`UPDATE world_entities SET owner_id = NULL WHERE owner_id IN (${idList(ids)})`,
+ )
+ out.characters = await db.exec(sql`DELETE FROM characters WHERE id IN (${idList(ids)})`)
+ }
+ out.identity_rows = await db.exec(
+ sql`DELETE FROM identity_adoptions WHERE subject_id IN (${idList(subjects)})`,
+ )
+ return out
+ })
+ },
+ }
+}
+
+interface ModRow {
+ id: string
+ name: string
+ version: string
+ target: string
+ runtime: string
+ manifest: unknown
+ pack: unknown
+ trust_tier: string
+ status: string
+ installed_by: string
+ installed_at: number
+ updated_at: number
+}
+
+interface GrantRow {
+ mod_id: string
+ capability: string
+ granted_by: string
+ granted_at: number
+ revoked_at: number | null
+ revoked_by: string | null
+}
+
+interface AuditRow {
+ id: number
+ mod_id: string
+ action: string
+ capability: string | null
+ actor: string
+ detail: unknown
+ at: number
+}
+
+interface PlacementRow {
+ mod_id: string
+ placement_key: string
+ entity_id: string | null
+ at: number
+}
+
+const rowToMod = (r: ModRow): ModInstallDto => ({
+ id: r.id,
+ name: r.name,
+ version: r.version,
+ target: r.target,
+ runtime: r.runtime,
+ manifest: (r.manifest ?? {}) as Record<string, unknown>,
+ pack: (r.pack ?? {}) as Record<string, unknown>,
+ trustTier: r.trust_tier as ModTrustTier,
+ status: r.status as ModStatus,
+ installedBy: r.installed_by,
+ installedAt: Number(r.installed_at),
+ updatedAt: Number(r.updated_at),
+})
+
+const rowToGrant = (r: GrantRow): ModGrantDto => ({
+ modId: r.mod_id,
+ capability: r.capability,
+ grantedBy: r.granted_by,
+ grantedAt: Number(r.granted_at),
+ revokedAt: r.revoked_at === null ? null : Number(r.revoked_at),
+ revokedBy: r.revoked_by,
+})
+
+export function createModRepository(db: Db): ModRepository {
+ return {
+ async list() {
+ const rows = await db.many<ModRow>(sql`SELECT * FROM mods ORDER BY installed_at, id`)
+ return rows.map(rowToMod)
+ },
+ async get(id) {
+ const row = await db.maybe<ModRow>(sql`SELECT * FROM mods WHERE id = ${id}`)
+ return row ? rowToMod(row) : null
+ },
+ async insert(mod) {
+ await db.exec(sql`
+ INSERT INTO mods (id, name, version, target, runtime, manifest, pack, trust_tier, status, installed_by, installed_at, updated_at)
+ VALUES (${mod.id}, ${mod.name}, ${mod.version}, ${mod.target}, ${mod.runtime},
+ ${sql.json(mod.manifest)}, ${sql.json(mod.pack)}, ${mod.trustTier}, ${mod.status},
+ ${mod.installedBy}, ${mod.installedAt}, ${mod.updatedAt})
+ `)
+ },
+ async setStatus(id, status, at) {
+ await db.exec(sql`UPDATE mods SET status = ${status}, updated_at = ${at} WHERE id = ${id}`)
+ },
+ async grants(modId) {
+ const rows = await db.many<GrantRow>(
+ sql`SELECT * FROM mod_grants WHERE mod_id = ${modId} ORDER BY capability`,
+ )
+ return rows.map(rowToGrant)
+ },
+ async upsertGrant(g) {
+ await db.exec(sql`
+ INSERT INTO mod_grants (mod_id, capability, granted_by, granted_at, revoked_at, revoked_by)
+ VALUES (${g.modId}, ${g.capability}, ${g.grantedBy}, ${g.grantedAt}, ${g.revokedAt}, ${g.revokedBy})
+ ON CONFLICT (mod_id, capability) DO UPDATE SET
+ granted_by = excluded.granted_by, granted_at = excluded.granted_at,
+ revoked_at = excluded.revoked_at, revoked_by = excluded.revoked_by
+ `)
+ },
+ async revokeGrant(modId, capability, by, at) {
+ const changes = await db.exec(sql`
+ UPDATE mod_grants SET revoked_at = ${at}, revoked_by = ${by}
+ WHERE mod_id = ${modId} AND capability = ${capability} AND revoked_at IS NULL
+ `)
+ return changes > 0
+ },
+ async audit(e) {
+ await db.exec(sql`
+ INSERT INTO mod_audit (mod_id, action, capability, actor, detail, at)
+ VALUES (${e.modId}, ${e.action}, ${e.capability}, ${e.actor},
+ ${e.detail === null || e.detail === undefined ? null : sql.json(e.detail)}, ${e.at})
+ `)
+ },
+ async auditLog(modId, limit) {
+ const rows = await db.many<AuditRow>(
+ sql`SELECT * FROM mod_audit WHERE mod_id = ${modId} ORDER BY id DESC LIMIT ${limit}`,
+ )
+ return rows.map((r): ModAuditDto => ({
+ id: Number(r.id),
+ modId: r.mod_id,
+ action: r.action,
+ capability: r.capability,
+ actor: r.actor,
+ detail: (r.detail ?? null) as Record<string, unknown> | null,
+ at: Number(r.at),
+ }))
+ },
+ async placements(modId) {
+ const rows = await db.many<PlacementRow>(
+ sql`SELECT * FROM mod_placements WHERE mod_id = ${modId} ORDER BY placement_key`,
+ )
+ return rows.map((r): ModPlacementDto => ({
+ modId: r.mod_id,
+ key: r.placement_key,
+ entityId: r.entity_id,
+ at: Number(r.at),
+ }))
+ },
+ async setPlacement(p) {
+ await db.exec(sql`
+ INSERT INTO mod_placements (mod_id, placement_key, entity_id, at)
+ VALUES (${p.modId}, ${p.key}, ${p.entityId}, ${p.at})
+ ON CONFLICT (mod_id, placement_key) DO UPDATE SET entity_id = excluded.entity_id, at = excluded.at
+ `)
+ },
+ async deletePlacement(modId, key) {
+ await db.exec(
+ sql`DELETE FROM mod_placements WHERE mod_id = ${modId} AND placement_key = ${key}`,
+ )
+ },
+ }
+}
+
+interface MirrorRow {
+ asset_hash: string
+ file_name: string
+ mime: string
+ bytes: number
+ status: string
+ media_id: string | null
+ public_url: string | null
+ attempts: number
+ last_error: string | null
+ next_attempt_at: number
+ updated_at: number
+}
+
+const rowToMirror = (r: MirrorRow): MediaMirrorDto => ({
+ assetHash: r.asset_hash,
+ fileName: r.file_name,
+ mime: r.mime,
+ bytes: Number(r.bytes),
+ status: r.status as MediaMirrorDto['status'],
+ mediaId: r.media_id,
+ publicUrl: r.public_url,
+ attempts: Number(r.attempts),
+ lastError: r.last_error,
+ nextAttemptAt: Number(r.next_attempt_at),
+ updatedAt: Number(r.updated_at),
+})
+
+export function createMediaMirrorRepository(db: Db): MediaMirrorRepository {
+ return {
+ async enqueue(asset, now) {
+ const changes = await db.exec(sql`
+ INSERT INTO media_mirrors (asset_hash, file_name, mime, bytes, status, attempts, next_attempt_at, updated_at)
+ VALUES (${asset.assetHash}, ${asset.fileName}, ${asset.mime}, ${asset.bytes}, 'pending', 0, ${now}, ${now})
+ ON CONFLICT (asset_hash) DO NOTHING
+ `)
+ return changes > 0
+ },
+ async get(assetHash) {
+ const row = await db.maybe<MirrorRow>(
+ sql`SELECT * FROM media_mirrors WHERE asset_hash = ${assetHash}`,
+ )
+ return row ? rowToMirror(row) : null
+ },
+ async due(now, limit) {
+ const rows = await db.many<MirrorRow>(sql`
+ SELECT * FROM media_mirrors WHERE status = 'pending' AND next_attempt_at <= ${now}
+ ORDER BY next_attempt_at, asset_hash LIMIT ${limit}
+ `)
+ return rows.map(rowToMirror)
+ },
+ async noteObject(assetHash, mediaId, now) {
+ await db.exec(
+ sql`UPDATE media_mirrors SET media_id = ${mediaId}, updated_at = ${now} WHERE asset_hash = ${assetHash}`,
+ )
+ },
+ async markMirrored(assetHash, mediaId, publicUrl, now) {
+ await db.exec(sql`
+ UPDATE media_mirrors SET status = 'mirrored', media_id = ${mediaId}, public_url = ${publicUrl},
+ last_error = NULL, attempts = attempts + 1, updated_at = ${now} WHERE asset_hash = ${assetHash}
+ `)
+ },
+ async markFailed(assetHash, error, nextAttemptAt, terminal, now) {
+ await db.exec(sql`
+ UPDATE media_mirrors SET status = ${terminal ? 'failed' : 'pending'}, last_error = ${error.slice(0, 500)},
+ attempts = attempts + 1, next_attempt_at = ${nextAttemptAt}, updated_at = ${now}
+ WHERE asset_hash = ${assetHash}
+ `)
+ },
+ async counts() {
+ const out: Record<MediaMirrorDto['status'], number> = { pending: 0, mirrored: 0, failed: 0 }
+ const rows = await db.many<{ status: MediaMirrorDto['status']; n: number }>(
+ sql`SELECT status, COUNT(*) AS n FROM media_mirrors GROUP BY status`,
+ )
+ for (const r of rows) out[r.status] = Number(r.n)
+ return out
+ },
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+import { sql, type Sql } from 'openvibe-sdk/db'
+
+/** Multi-row INSERT … ON CONFLICT chunks stay at or below this many rows. */
+export const BATCH_CHUNK = 500
+
+/** `($1, $2, …)` from a list of values, for IN clauses. Never call with an empty list. */
+export function idList(values: readonly string[]): Sql {
+ return sql.join(values.map((v) => sql`${v}`))
+}
+
+export function* chunks<T>(rows: readonly T[], size: number): Generator<T[]> {
+ for (let i = 0; i < rows.length; i += size) yield rows.slice(i, i + size)
+}
@@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
+import type { Db, Tx } from 'openvibe-sdk/db'
import type {
import type {
ConstraintDto,
ConstraintDto,
MediaMirrorDto,
MediaMirrorDto,
@@ -12,36 +13,38 @@ import type {
@@ -12,36 +13,38 @@ import type {
/**
/**
* Repository boundary: gameplay/server code never touches SQL or the
* Repository boundary: gameplay/server code never touches SQL or the
- * database driver. Writes are designed to be batched — the server flushes
- * dirty entities periodically and on shutdown, not per mutation.
+ * database driver. Every method is async (PostgreSQL through openvibe-sdk/db);
+ * the server's write-behind flusher batches world rows in one transaction
+ * rather than writing per mutation.
*/
*/
export interface WorldEntityRepository {
export interface WorldEntityRepository {
- loadAll(): WorldEntityDto[]
- /** Transactional batch upsert. */
- upsertMany(entities: readonly WorldEntityDto[]): void
- deleteMany(ids: readonly string[]): void
+ loadAll(): Promise<WorldEntityDto[]>
+ /** Batch upsert, chunked multi-row INSERT … ON CONFLICT. */
+ upsertMany(entities: readonly WorldEntityDto[]): Promise<void>
+ deleteMany(ids: readonly string[]): Promise<void>
/** Bulk removal used on world-definition changes (e.g. all resource nodes). */
/** Bulk removal used on world-definition changes (e.g. all resource nodes). */
- deleteByKind(kind: string): void
+ deleteByKind(kind: string): Promise<void>
}
}
export interface PlayerRepository {
export interface PlayerRepository {
- findByToken(token: string): PlayerDto | null
+ /** Looks a character up by account token; a guest token is hashed first. */
+ findByToken(token: string): Promise<PlayerDto | null>
/** All characters under an account token (max 3, ordered by slot). */
/** All characters under an account token (max 3, ordered by slot). */
- listByToken(token: string): PlayerDto[]
- findByTokenSlot(token: string, slot: number): PlayerDto | null
+ listByToken(token: string): Promise<PlayerDto[]>
+ findByTokenSlot(token: string, slot: number): Promise<PlayerDto | null>
/** Offline lookups (prop protection checks owners who are not connected). */
/** Offline lookups (prop protection checks owners who are not connected). */
- findById(id: string): PlayerDto | null
- upsert(player: PlayerDto): void
- upsertMany(players: readonly PlayerDto[]): void
+ findById(id: string): Promise<PlayerDto | null>
+ upsert(player: PlayerDto): Promise<void>
+ upsertMany(players: readonly PlayerDto[]): Promise<void>
/** World-change safety: move every player to the given spawn. */
/** World-change safety: move every player to the given spawn. */
- resetAllPositions(pos: [number, number, number], yaw: number): void
+ resetAllPositions(pos: [number, number, number], yaw: number): Promise<void>
}
}
export interface ConstraintRepository {
export interface ConstraintRepository {
- loadAll(): ConstraintDto[]
- upsertMany(constraints: readonly ConstraintDto[]): void
- deleteMany(ids: readonly string[]): void
+ loadAll(): Promise<ConstraintDto[]>
+ upsertMany(constraints: readonly ConstraintDto[]): Promise<void>
+ deleteMany(ids: readonly string[]): Promise<void>
}
}
/**
/**
@@ -61,7 +64,7 @@ export interface IdentityRepository {
@@ -61,7 +64,7 @@ export interface IdentityRepository {
guestToken: string,
guestToken: string,
subjectId: string,
subjectId: string,
now: number,
now: number,
- ): { moved: number; slot: number | null; full: boolean }
+ ): Promise<{ moved: number; slot: number | null; full: boolean }>
/**
/**
* Account merge (roadmap WS-B task 5, ADR-029; network.subject.merged): the folded-in subject's characters
* Account merge (roadmap WS-B task 5, ADR-029; network.subject.merged): the folded-in subject's characters
* move to the survivor, each into its own slot when free there, else the first free one (0..2). A character
* move to the survivor, each into its own slot when free there, else the first free one (0..2). A character
@@ -73,43 +76,43 @@ export interface IdentityRepository {
@@ -73,43 +76,43 @@ export interface IdentityRepository {
intoSubject: string,
intoSubject: string,
mergeId: string,
mergeId: string,
now: number,
now: number,
- ): { moved: number; kept: number; already: boolean }
+ ): Promise<{ moved: number; kept: number; already: boolean }>
/**
/**
* What Games keeps about a subject, for their data export (roadmap WS-B task 7, ADR-033): their characters (without
* What Games keeps about a subject, for their data export (roadmap WS-B task 7, ADR-033): their characters (without
- * the sign-in token) and the world structures those characters own.
+ * the account key) and the world structures those characters own.
*/
*/
- exportSubject(subject: string): {
+ exportSubject(subject: string): Promise<{
characters: Record<string, unknown>[]
characters: Record<string, unknown>[]
structures: Record<string, unknown>[]
structures: Record<string, unknown>[]
- }
+ }>
/**
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
* characters and adoption rows go; the structures their characters built stay in the world without an owner.
* characters and adoption rows go; the structures their characters built stay in the world without an owner.
* Returns counts.
* Returns counts.
*/
*/
- eraseSubjects(subjects: string[]): {
+ eraseSubjects(subjects: string[]): Promise<{
characters: number
characters: number
structures_unowned: number
structures_unowned: number
identity_rows: number
identity_rows: number
- }
+ }>
}
}
/** Installed mods, their approved capabilities, placements and audit log. */
/** Installed mods, their approved capabilities, placements and audit log. */
export interface ModRepository {
export interface ModRepository {
- list(): ModInstallDto[]
- get(id: string): ModInstallDto | null
- insert(mod: ModInstallDto): void
- setStatus(id: string, status: ModStatus, at: number): void
- grants(modId: string): ModGrantDto[]
+ list(): Promise<ModInstallDto[]>
+ get(id: string): Promise<ModInstallDto | null>
+ insert(mod: ModInstallDto): Promise<void>
+ setStatus(id: string, status: ModStatus, at: number): Promise<void>
+ grants(modId: string): Promise<ModGrantDto[]>
/** Grants (or re-grants) a capability. */
/** Grants (or re-grants) a capability. */
- upsertGrant(grant: ModGrantDto): void
+ upsertGrant(grant: ModGrantDto): Promise<void>
/** Returns false when the capability was not actively granted. */
/** Returns false when the capability was not actively granted. */
- revokeGrant(modId: string, capability: string, by: string, at: number): boolean
- audit(entry: ModAuditDto): void
- auditLog(modId: string, limit: number): ModAuditDto[]
- placements(modId: string): ModPlacementDto[]
- setPlacement(placement: ModPlacementDto): void
- deletePlacement(modId: string, key: string): void
+ revokeGrant(modId: string, capability: string, by: string, at: number): Promise<boolean>
+ audit(entry: ModAuditDto): Promise<void>
+ auditLog(modId: string, limit: number): Promise<ModAuditDto[]>
+ placements(modId: string): Promise<ModPlacementDto[]>
+ setPlacement(placement: ModPlacementDto): Promise<void>
+ deletePlacement(modId: string, key: string): Promise<void>
}
}
/** Queue of local assets to copy into OpenVibe.Media. */
/** Queue of local assets to copy into OpenVibe.Media. */
@@ -118,29 +121,47 @@ export interface MediaMirrorRepository {
@@ -118,29 +121,47 @@ export interface MediaMirrorRepository {
enqueue(
enqueue(
asset: { assetHash: string; fileName: string; mime: string; bytes: number },
asset: { assetHash: string; fileName: string; mime: string; bytes: number },
now: number,
now: number,
- ): boolean
- get(assetHash: string): MediaMirrorDto | null
+ ): Promise<boolean>
+ get(assetHash: string): Promise<MediaMirrorDto | null>
/** Pending rows whose next attempt is due, oldest first. */
/** Pending rows whose next attempt is due, oldest first. */
- due(now: number, limit: number): MediaMirrorDto[]
+ due(now: number, limit: number): Promise<MediaMirrorDto[]>
/** Remembers the Media object created for an asset before its bytes are confirmed. */
/** Remembers the Media object created for an asset before its bytes are confirmed. */
- noteObject(assetHash: string, mediaId: string, now: number): void
- markMirrored(assetHash: string, mediaId: string, publicUrl: string | null, now: number): void
+ noteObject(assetHash: string, mediaId: string, now: number): Promise<void>
+ markMirrored(
+ assetHash: string,
+ mediaId: string,
+ publicUrl: string | null,
+ now: number,
+ ): Promise<void>
markFailed(
markFailed(
assetHash: string,
assetHash: string,
error: string,
error: string,
nextAttemptAt: number,
nextAttemptAt: number,
terminal: boolean,
terminal: boolean,
now: number,
now: number,
- ): void
- counts(): Record<MediaMirrorDto['status'], number>
+ ): Promise<void>
+ counts(): Promise<Record<MediaMirrorDto['status'], number>>
}
}
+/** Per-place key/value state. Values are JSON: env_time, env_weather, market_<id>, … */
export interface MetaRepository {
export interface MetaRepository {
- get(key: string): string | null
- set(key: string, value: string): void
+ get(key: string): Promise<unknown | null>
+ set(key: string, value: unknown): Promise<void>
+ /** Every key beginning with `prefix`, for boot-time loads (markets). */
+ list(prefix: string): Promise<{ key: string; value: unknown }[]>
}
}
export interface PersistenceStore {
export interface PersistenceStore {
+ /**
+ * The openvibe-sdk/db handle. Exposed ONLY for apps/server's platform adapters that must share the
+ * exact same transaction as the state they describe — the event outbox (createPgOutbox), the token
+ * revocations (createPgRevocationStore) and the account-data adapter. Gameplay code goes through the
+ * repositories below and never touches this.
+ */
+ readonly db: Db
+ readonly placeId: string
+ /** Upserts this instance's configured place; call once at boot before any world write. */
+ ensurePlace(name?: string): Promise<void>
readonly worldEntities: WorldEntityRepository
readonly worldEntities: WorldEntityRepository
readonly players: PlayerRepository
readonly players: PlayerRepository
readonly constraints: ConstraintRepository
readonly constraints: ConstraintRepository
@@ -149,10 +170,12 @@ export interface PersistenceStore {
@@ -149,10 +170,12 @@ export interface PersistenceStore {
readonly mods: ModRepository
readonly mods: ModRepository
readonly mediaMirrors: MediaMirrorRepository
readonly mediaMirrors: MediaMirrorRepository
/**
/**
- * Runs `fn` in one transaction (nested calls become savepoints). Anything
- * written inside — world rows, player rows, outbox events — commits or
- * rolls back together.
+ * Runs `fn` in one transaction (a nested call becomes a savepoint). Anything written inside — world
+ * rows, character rows, outbox events — commits or rolls back together. Plain repository and db calls
+ * made inside `fn` join the transaction (the SDK's ambient mode). `fn` receives the SDK transaction
+ * handle: the outbox must be handed exactly this handle, so an event cannot be written on a different
+ * connection than the change it describes.
*/
*/
- transaction<T>(fn: () => T): T
- close(): void
+ transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T>
+ close(): Promise<void>
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-export { openSqliteStore, type SqlitePersistenceStore } from './sqliteStore.js'
deleted file mode 100644
deleted file mode 100644
@@ -1,285 +0,0 @@
@@ -1,285 +0,0 @@
-import { mkdtempSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
-import type { PlayerDto } from '../dto.js'
-import { openSqliteStore } from './sqliteStore.js'
-
-const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
-const OTHER = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'
-
-function player(
- id: string,
- token: string,
- slot: number,
- extra: Partial<PlayerDto> = {},
-): PlayerDto {
- return {
- id,
- token,
- name: `char-${id}`,
- pos: [0, 1, 0],
- yaw: 0,
- inventory: { size: 24, hotbar: 6, slots: [] },
- skills: { woodcutting: 10 },
- friends: [],
- appearance: null,
- charSlot: slot,
- armor: null,
- reputation: {},
- unlocks: [],
- activeJob: null,
- stats: null,
- updatedAt: 1,
- ...extra,
- }
-}
-
-describe('guest conversion (WS-B task 8)', () => {
- it('moves the guest character into the first free slot, once, keyed by a hash of the token', () => {
- const store = openSqliteStore(':memory:')
- store.players.upsertMany([player('g1', 'guest00042abc', 0), player('a0', SUBJECT, 0)])
- expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 10)).toEqual({
- moved: 1,
- slot: 1,
- full: false,
- })
- expect(
- store.players
- .listByToken(SUBJECT)
- .map((p) => [p.id, p.charSlot])
- .sort(),
- ).toEqual([
- ['a0', 0],
- ['g1', 1],
- ])
- expect(store.players.listByToken('guest00042abc')).toEqual([])
- expect(store.identity.adoptGuestCharacter('guest00042abc', SUBJECT, 11)).toEqual({
- moved: 0,
- slot: null,
- full: false,
- })
- const keys = (
- store.db.prepare('SELECT adoption_key FROM identity_adoptions').all() as {
- adoption_key: string
- }[]
- ).map((r) => r.adoption_key)
- expect(keys.some((k) => k.includes('guest00042abc'))).toBe(false)
- expect(keys.some((k) => /^guest:[0-9a-f]{32}$/.test(k))).toBe(true)
- store.close()
- })
-
- it('keeps the guest character when every slot is taken, and does nothing without one', () => {
- const store = openSqliteStore(':memory:')
- store.players.upsertMany([
- player('a0', SUBJECT, 0),
- player('a1', SUBJECT, 1),
- player('a2', SUBJECT, 2),
- player('g1', 'guestfull01', 0),
- ])
- expect(store.identity.adoptGuestCharacter('guestfull01', SUBJECT, 1)).toEqual({
- moved: 0,
- slot: null,
- full: true,
- })
- expect(store.players.listByToken('guestfull01').length).toBe(1)
- expect(store.identity.adoptGuestCharacter('nobodyhere01', SUBJECT, 1)).toEqual({
- moved: 0,
- slot: null,
- full: false,
- })
- store.close()
- })
-})
-
-describe('account merge (ADR-029)', () => {
- const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
- it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', () => {
- const store = openSqliteStore(':memory:')
- // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.
- store.players.upsertMany([
- player('s0', SUBJECT, 0),
- player('f0', FROM, 0),
- player('f1', FROM, 1),
- player('f2', FROM, 2),
- ])
- expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5)).toEqual(
- { moved: 2, kept: 1, already: false },
- )
- expect(
- store.players
- .listByToken(SUBJECT)
- .map((p) => [p.id, p.charSlot])
- .sort(),
- ).toEqual([
- ['f1', 1],
- ['f2', 2],
- ['s0', 0],
- ])
- expect(store.players.listByToken(FROM).map((p) => p.id)).toEqual(['f0'])
- expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6)).toEqual(
- { moved: 0, kept: 0, already: true },
- )
- expect(store.players.listByToken(FROM).length).toBe(1)
- store.close()
- })
-})
-
-describe('account export and deletion (ADR-033)', () => {
- const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
- it("exports the subject's characters without the token, then erases them and the alias's; structures stay unowned", () => {
- const store = openSqliteStore(':memory:')
- store.players.upsertMany([
- player('d0', SUBJECT, 0),
- player('d1', ALIAS, 0),
- player('o0', OTHER, 0),
- ])
- const entity = store.db.prepare(
- "INSERT INTO world_entities (id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at) VALUES (?, 'structure', 'hut', ?, 0, 0, 0, 0, 0, 0, 1, 'static', '{}', 1)",
- )
- entity.run('e1', 'd0')
- entity.run('e2', 'o0')
- store.db
- .prepare(
- "INSERT INTO identity_adoptions (adoption_key, subject_id, source, adopted_at) VALUES ('merge:mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', ?, 'merge', 1)",
- )
- .run(SUBJECT)
- const out = store.identity.exportSubject(SUBJECT)
- expect(out.characters.map((c) => c.id)).toEqual(['d0'])
- expect(out.characters[0]).not.toHaveProperty('token')
- expect(out.characters[0]?.skills).toEqual({ woodcutting: 10 })
- expect(out.structures.map((e) => e.id)).toEqual(['e1'])
- expect(store.identity.eraseSubjects([SUBJECT, ALIAS])).toEqual({
- characters: 2,
- structures_unowned: 1,
- identity_rows: 1,
- })
- expect(store.players.listByToken(SUBJECT)).toEqual([])
- expect(store.players.listByToken(ALIAS)).toEqual([])
- expect(store.players.listByToken(OTHER).map((p) => p.id)).toEqual(['o0'])
- expect(store.db.prepare('SELECT id, owner_id FROM world_entities ORDER BY id').all()).toEqual([
- { id: 'e1', owner_id: null },
- { id: 'e2', owner_id: 'o0' },
- ])
- expect(store.identity.eraseSubjects([SUBJECT])).toEqual({
- characters: 0,
- structures_unowned: 0,
- identity_rows: 0,
- })
- store.close()
- })
-})
-
-describe('player subject_id', () => {
- it('keeps subject_id through ordinary saves that do not carry it', () => {
- const store = openSqliteStore(':memory:')
- store.players.upsert(player('p1', SUBJECT, 0, { subjectId: SUBJECT }))
- const { subjectId: _drop, ...withoutSubject } = store.players.findById('p1')!
- store.players.upsert({ ...withoutSubject, name: 'renamed' })
- expect(store.players.findById('p1')).toMatchObject({ name: 'renamed', subjectId: SUBJECT })
- store.close()
- })
-})
-
-describe('mod repository', () => {
- it('stores installs, grants, placements and an append-only audit log', () => {
- const store = openSqliteStore(':memory:')
- store.mods.insert({
- id: 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0',
- name: 'Test',
- version: '1.0.0',
- target: 'games.browser',
- runtime: 'games-content@1',
- manifest: { a: 1 },
- pack: { props: [] },
- trustTier: 'unreviewed',
- status: 'disabled',
- installedBy: SUBJECT,
- installedAt: 1,
- updatedAt: 1,
- })
- const id = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'
- store.mods.upsertGrant({
- modId: id,
- capability: 'games.world.announce',
- grantedBy: SUBJECT,
- grantedAt: 2,
- revokedAt: null,
- revokedBy: null,
- })
- expect(store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 3)).toBe(true)
- expect(store.mods.revokeGrant(id, 'games.world.announce', SUBJECT, 4)).toBe(false)
- expect(store.mods.grants(id)[0]).toMatchObject({ revokedAt: 3, revokedBy: SUBJECT })
-
- store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e1', at: 5 })
- store.mods.setPlacement({ modId: id, key: 'bench', entityId: 'e2', at: 6 })
- expect(store.mods.placements(id)).toEqual([{ modId: id, key: 'bench', entityId: 'e2', at: 6 }])
- store.mods.deletePlacement(id, 'bench')
- expect(store.mods.placements(id)).toEqual([])
-
- store.mods.audit({
- modId: id,
- action: 'install',
- capability: null,
- actor: SUBJECT,
- detail: null,
- at: 1,
- })
- store.mods.audit({
- modId: id,
- action: 'grant',
- capability: 'games.world.announce',
- actor: SUBJECT,
- detail: { x: 1 },
- at: 2,
- })
- expect(store.mods.auditLog(id, 10).map((a) => a.action)).toEqual(['grant', 'install'])
- store.mods.setStatus(id, 'revoked', 9)
- expect(store.mods.get(id)).toMatchObject({
- status: 'revoked',
- updatedAt: 9,
- manifest: { a: 1 },
- })
- store.close()
- })
-})
-
-describe('media mirror queue', () => {
- it('queues once, schedules retries and records the Media object', () => {
- const store = openSqliteStore(':memory:')
- const asset = { assetHash: 'sha256-aa', fileName: 'sha256-aa.png', mime: 'image/png', bytes: 3 }
- expect(store.mediaMirrors.enqueue(asset, 10)).toBe(true)
- expect(store.mediaMirrors.enqueue(asset, 11)).toBe(false)
- expect(store.mediaMirrors.due(10, 5)).toHaveLength(1)
- store.mediaMirrors.markFailed('sha256-aa', 'http.503: down', 100, false, 12)
- expect(store.mediaMirrors.due(50, 5)).toHaveLength(0)
- expect(store.mediaMirrors.due(100, 5)).toHaveLength(1)
- store.mediaMirrors.noteObject('sha256-aa', 'med_X', 13)
- store.mediaMirrors.markMirrored('sha256-aa', 'med_X', 'https://openvibe.media/o/med_X', 14)
- expect(store.mediaMirrors.get('sha256-aa')).toMatchObject({
- status: 'mirrored',
- mediaId: 'med_X',
- attempts: 2,
- lastError: null,
- })
- expect(store.mediaMirrors.counts()).toEqual({ pending: 0, mirrored: 1, failed: 0 })
- store.close()
- })
-})
-
-describe('store transactions', () => {
- it('roll back every repository write together', () => {
- const dir = mkdtempSync(join(tmpdir(), 'openvibe-tx-'))
- const store = openSqliteStore(join(dir, 'world.db'))
- expect(() =>
- store.transaction(() => {
- store.players.upsert(player('p1', 'tok_12345678', 0))
- store.meta.set('env_time', '0.5')
- throw new Error('boom')
- }),
- ).toThrow('boom')
- expect(store.players.findById('p1')).toBeNull()
- expect(store.meta.get('env_time')).toBeNull()
- store.close()
- })
-})
deleted file mode 100644
deleted file mode 100644
@@ -1,431 +0,0 @@
@@ -1,431 +0,0 @@
-import { createHash } from 'node:crypto'
-import type Database from 'better-sqlite3'
-import type {
- MediaMirrorDto,
- ModAuditDto,
- ModGrantDto,
- ModInstallDto,
- ModPlacementDto,
- ModStatus,
- ModTrustTier,
-} from '../dto.js'
-import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
-
-/**
- * SQLite repositories for the platform-integration tables: the adoption
- * ledger, the mod registry and the Media mirror queue. The game simulation
- * never touches these; apps/server's platform adapters do.
- */
-
-export function createIdentityRepository(db: Database.Database): IdentityRepository {
- const takenSlots = db.prepare<[string], { char_slot: number }>(
- 'SELECT char_slot FROM players WHERE token = ?',
- )
- const rowsByToken = db.prepare<[string], { id: string; char_slot: number }>(
- 'SELECT id, char_slot FROM players WHERE token = ? ORDER BY char_slot',
- )
- const moveToSlot = db.prepare(
- 'UPDATE players SET token = ?, subject_id = ?, char_slot = ? WHERE id = ?',
- )
- const recordAdoption = db.prepare(`
- INSERT INTO identity_adoptions (adoption_key, subject_id, source, moved, conflicts, adopted_at)
- VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(adoption_key) DO UPDATE SET
- moved = identity_adoptions.moved + excluded.moved,
- conflicts = excluded.conflicts
- `)
- const adopted = db.prepare<[string], { subject_id: string }>(
- 'SELECT subject_id FROM identity_adoptions WHERE adoption_key = ?',
- )
-
- const adoptGuest = db.transaction((guestToken: string, subjectId: string, now: number) => {
- // Once per guest token, remembered by a hash of it, never the token.
- const key = `guest:${createHash('sha256').update(guestToken).digest('hex').slice(0, 32)}`
- const none = { moved: 0, slot: null, full: false }
- if (adopted.get(key)) return none
- const rows = rowsByToken.all(guestToken)
- if (rows.length === 0) return none
- const taken = new Set(takenSlots.all(subjectId).map((r) => r.char_slot))
- let moved = 0
- let slot: number | null = null
- for (const row of rows) {
- const free = [0, 1, 2].find((s) => !taken.has(s))
- if (free === undefined) break
- moveToSlot.run(subjectId, subjectId, free, row.id)
- taken.add(free)
- slot ??= free
- moved++
- }
- if (moved === 0) return { moved: 0, slot: null, full: true }
- recordAdoption.run(key, subjectId, 'guest', moved, rows.length - moved, now)
- return { moved, slot, full: false }
- })
-
- // Account merge (ADR-029): the folded-in subject's characters join the survivor, once per merge.
- const mergeAccount = db.transaction(
- (from: string, into: string, mergeId: string, now: number) => {
- const key = `merge:${mergeId}`
- if (adopted.get(key)) return { moved: 0, kept: 0, already: true }
- const taken = new Set(takenSlots.all(into).map((r) => r.char_slot))
- const rows = rowsByToken.all(from)
- let moved = 0
- // First every character whose own slot is free there, then the rest into what is left.
- const clashing = rows.filter((row) => {
- if (taken.has(row.char_slot)) return true
- moveToSlot.run(into, into, row.char_slot, row.id)
- taken.add(row.char_slot)
- moved++
- return false
- })
- let kept = 0
- for (const row of clashing) {
- const slot = [0, 1, 2].find((s) => !taken.has(s))
- if (slot === undefined) {
- kept++
- continue
- }
- moveToSlot.run(into, into, slot, row.id)
- taken.add(slot)
- moved++
- }
- recordAdoption.run(key, into, 'merge', moved, kept, now)
- return { moved, kept, already: false }
- },
- )
-
- // Account export and deletion (ADR-033). A character row keyed by the subject (token) or carrying it (subject_id).
- const inList = (n: number): string => `(${Array.from({ length: n }, () => '?').join(',')})`
- const parsed = (row: Record<string, unknown>): Record<string, unknown> => {
- const out: Record<string, unknown> = {}
- for (const [k, v] of Object.entries(row)) {
- if (k === 'token') continue
- if (typeof v === 'string' && (v.startsWith('{') || v.startsWith('['))) {
- try {
- out[k] = JSON.parse(v)
- continue
- } catch {
- // kept as text
- }
- }
- out[k] = v
- }
- return out
- }
- const exportSubject = (subject: string) => {
- const characters = db
- .prepare('SELECT * FROM players WHERE token = ? OR subject_id = ? ORDER BY char_slot')
- .all(subject, subject) as Record<string, unknown>[]
- const ids = characters.map((c) => String(c.id))
- const structures = ids.length
- ? (db
- .prepare(`SELECT * FROM world_entities WHERE owner_id IN ${inList(ids.length)}`)
- .all(...ids) as Record<string, unknown>[])
- : []
- return { characters: characters.map(parsed), structures: structures.map(parsed) }
- }
- const eraseSubjects = db.transaction((subjects: string[]) => {
- const out = { characters: 0, structures_unowned: 0, identity_rows: 0 }
- if (!subjects.length) return out
- const q = inList(subjects.length)
- const ids = (
- db
- .prepare(`SELECT id FROM players WHERE token IN ${q} OR subject_id IN ${q}`)
- .all(...subjects, ...subjects) as { id: string }[]
- ).map((r) => r.id)
- if (ids.length) {
- out.structures_unowned = db
- .prepare(
- `UPDATE world_entities SET owner_id = NULL WHERE owner_id IN ${inList(ids.length)}`,
- )
- .run(...ids).changes
- out.characters = db
- .prepare(`DELETE FROM players WHERE id IN ${inList(ids.length)}`)
- .run(...ids).changes
- }
- out.identity_rows = db
- .prepare(`DELETE FROM identity_adoptions WHERE subject_id IN ${q}`)
- .run(...subjects).changes
- return out
- })
-
- return {
- exportSubject,
- eraseSubjects: (subjects) => eraseSubjects(subjects),
- mergeSubject: (from, into, mergeId, now) => mergeAccount(from, into, mergeId, now),
- adoptGuestCharacter: (guestToken, subjectId, now) => adoptGuest(guestToken, subjectId, now),
- }
-}
-
-interface ModRow {
- id: string
- name: string
- version: string
- target: string
- runtime: string
- manifest: string
- pack: string
- trust_tier: string
- status: string
- installed_by: string
- installed_at: number
- updated_at: number
-}
-
-interface GrantRow {
- mod_id: string
- capability: string
- granted_by: string
- granted_at: number
- revoked_at: number | null
- revoked_by: string | null
-}
-
-interface AuditRow {
- id: number
- mod_id: string
- action: string
- capability: string | null
- actor: string
- detail: string | null
- at: number
-}
-
-interface PlacementRow {
- mod_id: string
- placement_key: string
- entity_id: string | null
- at: number
-}
-
-const rowToMod = (r: ModRow): ModInstallDto => ({
- id: r.id,
- name: r.name,
- version: r.version,
- target: r.target,
- runtime: r.runtime,
- manifest: JSON.parse(r.manifest) as Record<string, unknown>,
- pack: JSON.parse(r.pack) as Record<string, unknown>,
- trustTier: r.trust_tier as ModTrustTier,
- status: r.status as ModStatus,
- installedBy: r.installed_by,
- installedAt: r.installed_at,
- updatedAt: r.updated_at,
-})
-
-const rowToGrant = (r: GrantRow): ModGrantDto => ({
- modId: r.mod_id,
- capability: r.capability,
- grantedBy: r.granted_by,
- grantedAt: r.granted_at,
- revokedAt: r.revoked_at,
- revokedBy: r.revoked_by,
-})
-
-export function createModRepository(db: Database.Database): ModRepository {
- const insertMod = db.prepare(`
- INSERT INTO mods (id, name, version, target, runtime, manifest, pack, trust_tier, status, installed_by, installed_at, updated_at)
- VALUES (@id, @name, @version, @target, @runtime, @manifest, @pack, @trust_tier, @status, @installed_by, @installed_at, @updated_at)
- `)
- const upsertGrant = db.prepare(`
- INSERT INTO mod_grants (mod_id, capability, granted_by, granted_at, revoked_at, revoked_by)
- VALUES (@mod_id, @capability, @granted_by, @granted_at, @revoked_at, @revoked_by)
- ON CONFLICT(mod_id, capability) DO UPDATE SET
- granted_by = excluded.granted_by, granted_at = excluded.granted_at,
- revoked_at = excluded.revoked_at, revoked_by = excluded.revoked_by
- `)
- const insertAudit = db.prepare(`
- INSERT INTO mod_audit (mod_id, action, capability, actor, detail, at)
- VALUES (?, ?, ?, ?, ?, ?)
- `)
- const setPlacement = db.prepare(`
- INSERT INTO mod_placements (mod_id, placement_key, entity_id, at) VALUES (?, ?, ?, ?)
- ON CONFLICT(mod_id, placement_key) DO UPDATE SET entity_id = excluded.entity_id, at = excluded.at
- `)
-
- return {
- list() {
- return (db.prepare('SELECT * FROM mods ORDER BY installed_at, id').all() as ModRow[]).map(
- rowToMod,
- )
- },
- get(id) {
- const row = db.prepare('SELECT * FROM mods WHERE id = ?').get(id) as ModRow | undefined
- return row ? rowToMod(row) : null
- },
- insert(mod) {
- insertMod.run({
- id: mod.id,
- name: mod.name,
- version: mod.version,
- target: mod.target,
- runtime: mod.runtime,
- manifest: JSON.stringify(mod.manifest),
- pack: JSON.stringify(mod.pack),
- trust_tier: mod.trustTier,
- status: mod.status,
- installed_by: mod.installedBy,
- installed_at: mod.installedAt,
- updated_at: mod.updatedAt,
- })
- },
- setStatus(id, status, at) {
- db.prepare('UPDATE mods SET status = ?, updated_at = ? WHERE id = ?').run(status, at, id)
- },
- grants(modId) {
- return (
- db
- .prepare('SELECT * FROM mod_grants WHERE mod_id = ? ORDER BY capability')
- .all(modId) as GrantRow[]
- ).map(rowToGrant)
- },
- upsertGrant(g) {
- upsertGrant.run({
- mod_id: g.modId,
- capability: g.capability,
- granted_by: g.grantedBy,
- granted_at: g.grantedAt,
- revoked_at: g.revokedAt,
- revoked_by: g.revokedBy,
- })
- },
- revokeGrant(modId, capability, by, at) {
- const r = db
- .prepare(
- 'UPDATE mod_grants SET revoked_at = ?, revoked_by = ? WHERE mod_id = ? AND capability = ? AND revoked_at IS NULL',
- )
- .run(at, by, modId, capability)
- return r.changes > 0
- },
- audit(e) {
- insertAudit.run(
- e.modId,
- e.action,
- e.capability,
- e.actor,
- e.detail ? JSON.stringify(e.detail) : null,
- e.at,
- )
- },
- auditLog(modId, limit) {
- return (
- db
- .prepare('SELECT * FROM mod_audit WHERE mod_id = ? ORDER BY id DESC LIMIT ?')
- .all(modId, limit) as AuditRow[]
- ).map((r): ModAuditDto => ({
- id: r.id,
- modId: r.mod_id,
- action: r.action,
- capability: r.capability,
- actor: r.actor,
- detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : null,
- at: r.at,
- }))
- },
- placements(modId) {
- return (
- db
- .prepare('SELECT * FROM mod_placements WHERE mod_id = ? ORDER BY placement_key')
- .all(modId) as PlacementRow[]
- ).map((r): ModPlacementDto => ({
- modId: r.mod_id,
- key: r.placement_key,
- entityId: r.entity_id,
- at: r.at,
- }))
- },
- setPlacement(p) {
- setPlacement.run(p.modId, p.key, p.entityId, p.at)
- },
- deletePlacement(modId, key) {
- db.prepare('DELETE FROM mod_placements WHERE mod_id = ? AND placement_key = ?').run(
- modId,
- key,
- )
- },
- }
-}
-
-interface MirrorRow {
- asset_hash: string
- file_name: string
- mime: string
- bytes: number
- status: string
- media_id: string | null
- public_url: string | null
- attempts: number
- last_error: string | null
- next_attempt_at: number
- updated_at: number
-}
-
-const rowToMirror = (r: MirrorRow): MediaMirrorDto => ({
- assetHash: r.asset_hash,
- fileName: r.file_name,
- mime: r.mime,
- bytes: r.bytes,
- status: r.status as MediaMirrorDto['status'],
- mediaId: r.media_id,
- publicUrl: r.public_url,
- attempts: r.attempts,
- lastError: r.last_error,
- nextAttemptAt: r.next_attempt_at,
- updatedAt: r.updated_at,
-})
-
-export function createMediaMirrorRepository(db: Database.Database): MediaMirrorRepository {
- return {
- enqueue(asset, now) {
- const r = db
- .prepare(
- `INSERT OR IGNORE INTO media_mirrors (asset_hash, file_name, mime, bytes, status, attempts, next_attempt_at, updated_at)
- VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)`,
- )
- .run(asset.assetHash, asset.fileName, asset.mime, asset.bytes, now, now)
- return r.changes > 0
- },
- get(assetHash) {
- const row = db.prepare('SELECT * FROM media_mirrors WHERE asset_hash = ?').get(assetHash) as
- MirrorRow | undefined
- return row ? rowToMirror(row) : null
- },
- due(now, limit) {
- return (
- db
- .prepare(
- `SELECT * FROM media_mirrors WHERE status = 'pending' AND next_attempt_at <= ?
- ORDER BY next_attempt_at, asset_hash LIMIT ?`,
- )
- .all(now, limit) as MirrorRow[]
- ).map(rowToMirror)
- },
- noteObject(assetHash, mediaId, now) {
- db.prepare('UPDATE media_mirrors SET media_id = ?, updated_at = ? WHERE asset_hash = ?').run(
- mediaId,
- now,
- assetHash,
- )
- },
- markMirrored(assetHash, mediaId, publicUrl, now) {
- db.prepare(
- `UPDATE media_mirrors SET status = 'mirrored', media_id = ?, public_url = ?, last_error = NULL,
- attempts = attempts + 1, updated_at = ? WHERE asset_hash = ?`,
- ).run(mediaId, publicUrl, now, assetHash)
- },
- markFailed(assetHash, error, nextAttemptAt, terminal, now) {
- db.prepare(
- `UPDATE media_mirrors SET status = ?, last_error = ?, attempts = attempts + 1,
- next_attempt_at = ?, updated_at = ? WHERE asset_hash = ?`,
- ).run(terminal ? 'failed' : 'pending', error.slice(0, 500), nextAttemptAt, now, assetHash)
- },
- counts() {
- const out: Record<MediaMirrorDto['status'], number> = { pending: 0, mirrored: 0, failed: 0 }
- for (const r of db
- .prepare('SELECT status, COUNT(*) AS n FROM media_mirrors GROUP BY status')
- .all() as { status: MediaMirrorDto['status']; n: number }[]) {
- out[r.status] = r.n
- }
- return out
- },
- }
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,183 +0,0 @@
@@ -1,183 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { PlayerDto, WorldEntityDto } from '../dto.js'
-import { openSqliteStore } from './sqliteStore.js'
-
-function makeEntity(id: string): WorldEntityDto {
- return {
- id,
- kind: 'prop',
- defId: 'wooden_crate',
- ownerId: 'player1',
- pos: [1, 2, 3],
- rot: [0, 0, 0, 1],
- motion: 'dynamic',
- state: null,
- updatedAt: 1000,
- }
-}
-
-describe('sqlite store', () => {
- it('roundtrips world entities with batch upsert', () => {
- const store = openSqliteStore(':memory:')
- store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])
- const loaded = store.worldEntities.loadAll()
- expect(loaded).toHaveLength(2)
- expect(loaded.find((e) => e.id === 'a')).toEqual(makeEntity('a'))
-
- // update in place
- const moved = {
- ...makeEntity('a'),
- pos: [9, 9, 9] as [number, number, number],
- motion: 'frozen' as const,
- }
- store.worldEntities.upsertMany([moved])
- expect(store.worldEntities.loadAll().find((e) => e.id === 'a')).toEqual(moved)
-
- store.worldEntities.deleteMany(['a'])
- expect(store.worldEntities.loadAll()).toHaveLength(1)
- store.close()
- })
-
- it('roundtrips players by token including inventory json', () => {
- const store = openSqliteStore(':memory:')
- const player: PlayerDto = {
- id: 'p1',
- token: 'tok_12345678',
- name: 'Scrapper',
- pos: [0, 1, 0],
- yaw: 1.5,
- armor: { defId: 'padded_jacket', count: 1, meta: { dur: 12 } },
- reputation: { openvibeville: 12, rustjaw: -30 },
- unlocks: ['craft_still'],
- activeJob: { job: 'lumber_run', progress: 3 },
- inventory: {
- size: 24,
- hotbar: 6,
- slots: [{ i: 0, stack: { defId: 'wood_plank', count: 10 } }],
- },
- skills: { woodcutting: 120, mining: 40 },
- friends: ['p2', 'p3'],
- stats: { health: 90, hunger: 80, thirst: 70, stamina: 100 },
- charSlot: 0,
- appearance: {
- body: 'female',
- skin: 3,
- hairStyle: 'bun',
- hairColor: 2,
- facialHair: 'none',
- top: 1,
- bottom: 2,
- shoes: 3,
- height: 1.02,
- build: 0.95,
- },
- updatedAt: 2000,
- }
- store.players.upsert(player)
- expect(store.players.findByToken('tok_12345678')).toEqual(player)
- expect(store.players.findByToken('nope')).toBeNull()
- store.close()
- })
-
- it('roundtrips constraints', () => {
- const store = openSqliteStore(':memory:')
- store.worldEntities.upsertMany([makeEntity('a'), makeEntity('b')])
- const params = {
- anchorA: [0.5, 0, 0],
- anchorB: [-0.5, 0, 0],
- axisA: [0, 1, 0],
- axisB: [0, 1, 0],
- limits: { min: -1, max: 1 },
- }
- store.constraints.upsertMany([
- { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },
- { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },
- ])
- expect(store.constraints.loadAll()).toEqual([
- { id: 'c1', type: 'weld', entityA: 'a', entityB: 'b', params: null, updatedAt: 100 },
- { id: 'c2', type: 'hinge', entityA: 'a', entityB: 'b', params, updatedAt: 100 },
- ])
- store.constraints.deleteMany(['c1', 'c2'])
- expect(store.constraints.loadAll()).toEqual([])
- store.close()
- })
-
- it('migrates a v1 database to the current schema', async () => {
- const { default: Database } = await import('better-sqlite3')
- const { mkdtempSync } = await import('node:fs')
- const { tmpdir } = await import('node:os')
- const { join } = await import('node:path')
- const dir = mkdtempSync(join(tmpdir(), 'openvibe-migrate-'))
- const path = join(dir, 'v1.db')
-
- // Build a genuine v1 database (no skills column, no constraints table).
- const v1 = new Database(path)
- v1.exec(`
- CREATE TABLE world_entities (
- id TEXT PRIMARY KEY, kind TEXT NOT NULL, def_id TEXT NOT NULL, owner_id TEXT,
- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,
- rot_x REAL NOT NULL, rot_y REAL NOT NULL, rot_z REAL NOT NULL, rot_w REAL NOT NULL,
- motion TEXT NOT NULL, state TEXT, updated_at INTEGER NOT NULL
- );
- CREATE TABLE players (
- id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL, yaw REAL NOT NULL,
- inventory TEXT NOT NULL, updated_at INTEGER NOT NULL
- );
- CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
- INSERT INTO meta VALUES ('schema_version', '1');
- INSERT INTO players VALUES ('p1', 'tok_11111111', 'Old Scrapper', 1, 2, 3, 0.5,
- '{"size":24,"hotbar":6,"slots":[]}', 999);
- `)
- v1.close()
-
- const store = openSqliteStore(path)
- const player = store.players.findByToken('tok_11111111')
- expect(player?.name).toBe('Old Scrapper')
- expect(player?.skills).toEqual({})
- expect(player?.friends).toEqual([])
- expect(player?.appearance).toBeNull()
- // constraints table exists and works post-migration (incl. the params
- // column added by a later step)
- store.constraints.upsertMany([
- { id: 'c1', type: 'weld', entityA: 'x', entityB: 'y', params: null, updatedAt: 1 },
- {
- id: 'c2',
- type: 'rope',
- entityA: 'x',
- entityB: 'y',
- params: { length: 2 },
- updatedAt: 1,
- },
- ])
- const loaded = store.constraints.loadAll()
- expect(loaded).toHaveLength(2)
- expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })
- expect(store.meta.get('schema_version')).toBe('11')
- // The armor column round-trips.
- store.players.upsert({
- ...store.players.findByToken('tok_11111111')!,
- armor: { defId: 'padded_jacket', count: 1, meta: { dur: 5 } },
- })
- expect(store.players.findByToken('tok_11111111')?.armor).toEqual({
- defId: 'padded_jacket',
- count: 1,
- meta: { dur: 5 },
- })
- store.close()
- })
-
- it('persists resource state json', () => {
- const store = openSqliteStore(':memory:')
- const node: WorldEntityDto = {
- ...makeEntity('r1'),
- kind: 'resource',
- defId: 'scrap_metal',
- motion: 'static',
- state: { remaining: 12, perUse: 2 },
- }
- store.worldEntities.upsertMany([node])
- expect(store.worldEntities.loadAll()[0]?.state).toEqual({ remaining: 12, perUse: 2 })
- store.close()
- })
-})
deleted file mode 100644
deleted file mode 100644
@@ -1,530 +0,0 @@
@@ -1,530 +0,0 @@
-import Database from 'better-sqlite3'
-import type { InventoryDto, SkillsDto } from '@openvibe/gameplay'
-import type { ConstraintDto, PlayerDto, WorldEntityDto } from '../dto.js'
-import type {
- ConstraintRepository,
- MetaRepository,
- PersistenceStore,
- PlayerRepository,
- WorldEntityRepository,
-} from '../repositories.js'
-import {
- createIdentityRepository,
- createMediaMirrorRepository,
- createModRepository,
-} from './platformRepositories.js'
-
-/**
- * The SQLite store plus its database handle. Only apps/server's platform
- * adapters use `db` directly — for the event outbox, whose rows must commit
- * in the same transaction as the game state they describe.
- */
-export interface SqlitePersistenceStore extends PersistenceStore {
- readonly db: Database.Database
-}
-
-/**
- * SQLite implementation. Synchronous better-sqlite3 is intentional: batched
- * transactional writes from the flush system are microseconds-scale and far
- * simpler to reason about than async write queues.
- *
- * Schema changes are forward-only migrations keyed off meta.schema_version;
- * a database is upgraded step by step inside a transaction per step.
- */
-
-const SCHEMA_VERSION = 11
-
-const BASE_SCHEMA = `
-CREATE TABLE IF NOT EXISTS world_entities (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- def_id TEXT NOT NULL,
- owner_id TEXT,
- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,
- rot_x REAL NOT NULL, rot_y REAL NOT NULL, rot_z REAL NOT NULL, rot_w REAL NOT NULL,
- motion TEXT NOT NULL,
- state TEXT,
- updated_at INTEGER NOT NULL
-);
-CREATE TABLE IF NOT EXISTS players (
- id TEXT PRIMARY KEY,
- token TEXT NOT NULL,
- char_slot INTEGER NOT NULL DEFAULT 0,
- name TEXT NOT NULL,
- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,
- yaw REAL NOT NULL,
- inventory TEXT NOT NULL,
- skills TEXT NOT NULL DEFAULT '{}',
- friends TEXT NOT NULL DEFAULT '[]',
- appearance TEXT,
- stats TEXT,
- armor TEXT,
- reputation TEXT NOT NULL DEFAULT '{}',
- unlocks TEXT NOT NULL DEFAULT '[]',
- active_job TEXT,
- subject_id TEXT,
- updated_at INTEGER NOT NULL
-);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_players_token_slot ON players (token, char_slot);
-CREATE TABLE IF NOT EXISTS constraints (
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL,
- entity_a TEXT NOT NULL,
- entity_b TEXT NOT NULL,
- params TEXT,
- updated_at INTEGER NOT NULL
-);
-CREATE TABLE IF NOT EXISTS meta (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
-);
-`
-
-/**
- * Platform integration tables: canonical subject accounts, the adoption
- * ledger, the mod registry and the Media mirror queue. Shared by the
- * fresh-database path and the migration that adds them so both end up
- * identical.
- */
-const PLATFORM_SCHEMA = `
-CREATE INDEX IF NOT EXISTS idx_players_subject ON players (subject_id);
-CREATE TABLE IF NOT EXISTS identity_adoptions (
- adoption_key TEXT PRIMARY KEY,
- subject_id TEXT NOT NULL,
- source TEXT NOT NULL,
- moved INTEGER NOT NULL DEFAULT 0,
- conflicts INTEGER NOT NULL DEFAULT 0,
- adopted_at INTEGER NOT NULL
-);
-CREATE TABLE IF NOT EXISTS mods (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- version TEXT NOT NULL,
- target TEXT NOT NULL,
- runtime TEXT NOT NULL,
- manifest TEXT NOT NULL,
- pack TEXT NOT NULL,
- trust_tier TEXT NOT NULL,
- status TEXT NOT NULL,
- installed_by TEXT NOT NULL,
- installed_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-CREATE TABLE IF NOT EXISTS mod_grants (
- mod_id TEXT NOT NULL,
- capability TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at INTEGER NOT NULL,
- revoked_at INTEGER,
- revoked_by TEXT,
- PRIMARY KEY (mod_id, capability)
-);
-CREATE TABLE IF NOT EXISTS mod_placements (
- mod_id TEXT NOT NULL,
- placement_key TEXT NOT NULL,
- entity_id TEXT,
- at INTEGER NOT NULL,
- PRIMARY KEY (mod_id, placement_key)
-);
-CREATE TABLE IF NOT EXISTS mod_audit (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- mod_id TEXT NOT NULL,
- action TEXT NOT NULL,
- capability TEXT,
- actor TEXT NOT NULL,
- detail TEXT,
- at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_mod_audit_mod ON mod_audit (mod_id, id);
-CREATE TABLE IF NOT EXISTS media_mirrors (
- asset_hash TEXT PRIMARY KEY,
- file_name TEXT NOT NULL,
- mime TEXT NOT NULL,
- bytes INTEGER NOT NULL,
- status TEXT NOT NULL,
- media_id TEXT,
- public_url TEXT,
- attempts INTEGER NOT NULL DEFAULT 0,
- last_error TEXT,
- next_attempt_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_media_mirrors_due ON media_mirrors (status, next_attempt_at);
-`
-
-/** Migration from version N applies index N-1. Each runs in a transaction. */
-const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
- 1: (db) => {
- db.exec(`
- ALTER TABLE players ADD COLUMN skills TEXT NOT NULL DEFAULT '{}';
- CREATE TABLE IF NOT EXISTS constraints (
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL,
- entity_a TEXT NOT NULL,
- entity_b TEXT NOT NULL,
- updated_at INTEGER NOT NULL
- );
- `)
- },
- 2: (db) => {
- db.exec("ALTER TABLE players ADD COLUMN friends TEXT NOT NULL DEFAULT '[]';")
- },
- 3: (db) => {
- db.exec('ALTER TABLE players ADD COLUMN appearance TEXT;')
- },
- 4: (db) => {
- db.exec('ALTER TABLE players ADD COLUMN stats TEXT;')
- },
- 5: (db) => {
- // Multi-character accounts: token is no longer unique — (token, slot)
- // is. SQLite can't drop a UNIQUE constraint, so rebuild the table.
- db.exec(`
- ALTER TABLE players RENAME TO players_old;
- CREATE TABLE players (
- id TEXT PRIMARY KEY,
- token TEXT NOT NULL,
- char_slot INTEGER NOT NULL DEFAULT 0,
- name TEXT NOT NULL,
- pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL,
- yaw REAL NOT NULL,
- inventory TEXT NOT NULL,
- skills TEXT NOT NULL DEFAULT '{}',
- friends TEXT NOT NULL DEFAULT '[]',
- appearance TEXT,
- stats TEXT,
- updated_at INTEGER NOT NULL
- );
- INSERT INTO players (id, token, char_slot, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, updated_at)
- SELECT id, token, 0, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, updated_at FROM players_old;
- DROP TABLE players_old;
- CREATE UNIQUE INDEX idx_players_token_slot ON players (token, char_slot);
- `)
- },
- 6: (db) => {
- // Sandbox constraint set: type-specific parameters (anchors, axes,
- // lengths, limits, motor). Existing weld rows carry NULL params.
- db.exec('ALTER TABLE constraints ADD COLUMN params TEXT;')
- },
- 7: (db) => {
- // Worn armor (one slot; durability rides in the stack meta).
- db.exec('ALTER TABLE players ADD COLUMN armor TEXT;')
- },
- 8: (db) => {
- // Faction reputation scores.
- db.exec("ALTER TABLE players ADD COLUMN reputation TEXT NOT NULL DEFAULT '{}';")
- },
- 9: (db) => {
- // Blueprint unlocks + active contract.
- db.exec(`
- ALTER TABLE players ADD COLUMN unlocks TEXT NOT NULL DEFAULT '[]';
- ALTER TABLE players ADD COLUMN active_job TEXT;
- `)
- },
- 10: (db) => {
- // Platform integration: canonical subject per account (ADR-0006), the
- // adoption ledger, the mod registry and the Media mirror queue.
- db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')
- db.exec(PLATFORM_SCHEMA)
- },
-}
-
-interface WorldEntityRow {
- id: string
- kind: string
- def_id: string
- owner_id: string | null
- pos_x: number
- pos_y: number
- pos_z: number
- rot_x: number
- rot_y: number
- rot_z: number
- rot_w: number
- motion: string
- state: string | null
- updated_at: number
-}
-
-interface PlayerRow {
- id: string
- token: string
- name: string
- pos_x: number
- pos_y: number
- pos_z: number
- yaw: number
- inventory: string
- skills: string
- friends: string
- appearance: string | null
- stats: string | null
- armor: string | null
- reputation: string
- unlocks: string
- active_job: string | null
- subject_id: string | null
- char_slot: number
- updated_at: number
-}
-
-interface ConstraintRow {
- id: string
- type: string
- entity_a: string
- entity_b: string
- params: string | null
- updated_at: number
-}
-
-export function openSqliteStore(path: string): SqlitePersistenceStore {
- const db = new Database(path)
- db.pragma('journal_mode = WAL')
- db.pragma('synchronous = NORMAL')
-
- const hasMeta = db
- .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='meta'")
- .get()
- if (!hasMeta) {
- // Fresh database: create the full current schema.
- db.exec(BASE_SCHEMA)
- db.exec(PLATFORM_SCHEMA)
- db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
- 'schema_version',
- String(SCHEMA_VERSION),
- )
- } else {
- let version = Number(
- (
- db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as
- { value: string } | undefined
- )?.value ?? '1',
- )
- if (version > SCHEMA_VERSION) {
- throw new Error(`database schema ${version} is newer than this build (${SCHEMA_VERSION})`)
- }
- while (version < SCHEMA_VERSION) {
- const migrate = MIGRATIONS[version]
- if (!migrate) throw new Error(`missing migration from schema version ${version}`)
- db.transaction(() => {
- migrate(db)
- db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
- 'schema_version',
- String(version + 1),
- )
- })()
- version++
- }
- }
-
- const metaGet = db.prepare<[string], { value: string }>('SELECT value FROM meta WHERE key = ?')
- const metaSet = db.prepare(
- 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
- )
-
- const upsertEntity = db.prepare(`
- INSERT INTO world_entities (id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at)
- VALUES (@id, @kind, @def_id, @owner_id, @pos_x, @pos_y, @pos_z, @rot_x, @rot_y, @rot_z, @rot_w, @motion, @state, @updated_at)
- ON CONFLICT(id) DO UPDATE SET
- pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,
- rot_x=excluded.rot_x, rot_y=excluded.rot_y, rot_z=excluded.rot_z, rot_w=excluded.rot_w,
- motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at
- `)
- const deleteEntity = db.prepare('DELETE FROM world_entities WHERE id = ?')
- const selectEntities = db.prepare<[], WorldEntityRow>('SELECT * FROM world_entities')
-
- const worldEntities: WorldEntityRepository = {
- loadAll(): WorldEntityDto[] {
- return selectEntities.all().map((row) => ({
- id: row.id,
- kind: row.kind as WorldEntityDto['kind'],
- defId: row.def_id,
- ownerId: row.owner_id,
- pos: [row.pos_x, row.pos_y, row.pos_z],
- rot: [row.rot_x, row.rot_y, row.rot_z, row.rot_w],
- motion: row.motion as WorldEntityDto['motion'],
- state: row.state ? (JSON.parse(row.state) as WorldEntityDto['state']) : null,
- updatedAt: row.updated_at,
- }))
- },
- upsertMany: db.transaction((entities: readonly WorldEntityDto[]) => {
- for (const e of entities) {
- upsertEntity.run({
- id: e.id,
- kind: e.kind,
- def_id: e.defId,
- owner_id: e.ownerId,
- pos_x: e.pos[0],
- pos_y: e.pos[1],
- pos_z: e.pos[2],
- rot_x: e.rot[0],
- rot_y: e.rot[1],
- rot_z: e.rot[2],
- rot_w: e.rot[3],
- motion: e.motion,
- state: e.state ? JSON.stringify(e.state) : null,
- updated_at: e.updatedAt,
- })
- }
- }) as (entities: readonly WorldEntityDto[]) => void,
- deleteMany: db.transaction((ids: readonly string[]) => {
- for (const id of ids) deleteEntity.run(id)
- }) as (ids: readonly string[]) => void,
- deleteByKind(kind: string): void {
- db.prepare('DELETE FROM world_entities WHERE kind = ?').run(kind)
- },
- }
-
- const upsertPlayer = db.prepare(`
- INSERT INTO players (id, token, char_slot, name, pos_x, pos_y, pos_z, yaw, inventory, skills, friends, appearance, stats, armor, reputation, unlocks, active_job, subject_id, updated_at)
- VALUES (@id, @token, @char_slot, @name, @pos_x, @pos_y, @pos_z, @yaw, @inventory, @skills, @friends, @appearance, @stats, @armor, @reputation, @unlocks, @active_job, @subject_id, @updated_at)
- ON CONFLICT(id) DO UPDATE SET
- subject_id=COALESCE(excluded.subject_id, players.subject_id),
- name=excluded.name, pos_x=excluded.pos_x, pos_y=excluded.pos_y, pos_z=excluded.pos_z,
- yaw=excluded.yaw, inventory=excluded.inventory, skills=excluded.skills, friends=excluded.friends, appearance=excluded.appearance, stats=excluded.stats, armor=excluded.armor, reputation=excluded.reputation, unlocks=excluded.unlocks, active_job=excluded.active_job, updated_at=excluded.updated_at
- `)
- const selectPlayerByToken = db.prepare<[string], PlayerRow>(
- 'SELECT * FROM players WHERE token = ?',
- )
- const selectPlayerById = db.prepare<[string], PlayerRow>('SELECT * FROM players WHERE id = ?')
-
- const rowToPlayer = (row: PlayerRow): PlayerDto => ({
- id: row.id,
- token: row.token,
- name: row.name,
- pos: [row.pos_x, row.pos_y, row.pos_z],
- yaw: row.yaw,
- inventory: JSON.parse(row.inventory) as InventoryDto,
- skills: JSON.parse(row.skills || '{}') as SkillsDto,
- friends: JSON.parse(row.friends || '[]') as string[],
- appearance: row.appearance ? (JSON.parse(row.appearance) as PlayerDto['appearance']) : null,
- armor: row.armor ? (JSON.parse(row.armor) as PlayerDto['armor']) : null,
- reputation: row.reputation ? (JSON.parse(row.reputation) as PlayerDto['reputation']) : {},
- unlocks: row.unlocks ? (JSON.parse(row.unlocks) as string[]) : [],
- activeJob: row.active_job ? (JSON.parse(row.active_job) as PlayerDto['activeJob']) : null,
- stats: row.stats ? (JSON.parse(row.stats) as PlayerDto['stats']) : null,
- charSlot: row.char_slot ?? 0,
- ...(row.subject_id ? { subjectId: row.subject_id } : {}),
- updatedAt: row.updated_at,
- })
-
- const playerToRow = (p: PlayerDto) => ({
- id: p.id,
- token: p.token,
- name: p.name,
- pos_x: p.pos[0],
- pos_y: p.pos[1],
- pos_z: p.pos[2],
- yaw: p.yaw,
- inventory: JSON.stringify(p.inventory),
- skills: JSON.stringify(p.skills),
- friends: JSON.stringify(p.friends),
- appearance: p.appearance ? JSON.stringify(p.appearance) : null,
- stats: p.stats ? JSON.stringify(p.stats) : null,
- armor: p.armor ? JSON.stringify(p.armor) : null,
- reputation: JSON.stringify(p.reputation ?? {}),
- unlocks: JSON.stringify(p.unlocks ?? []),
- active_job: p.activeJob ? JSON.stringify(p.activeJob) : null,
- char_slot: p.charSlot ?? 0,
- subject_id: p.subjectId ?? null,
- updated_at: p.updatedAt,
- })
-
- const players: PlayerRepository = {
- listByToken(token: string): PlayerDto[] {
- const rows = db
- .prepare('SELECT * FROM players WHERE token = ? ORDER BY char_slot')
- .all(token) as PlayerRow[]
- return rows.map(rowToPlayer)
- },
- findByTokenSlot(token: string, slot: number): PlayerDto | null {
- const row = db
- .prepare('SELECT * FROM players WHERE token = ? AND char_slot = ?')
- .get(token, slot) as PlayerRow | undefined
- return row ? rowToPlayer(row) : null
- },
- findByToken(token: string): PlayerDto | null {
- const row = selectPlayerByToken.get(token)
- return row ? rowToPlayer(row) : null
- },
- findById(id: string): PlayerDto | null {
- const row = selectPlayerById.get(id)
- return row ? rowToPlayer(row) : null
- },
- upsert(player: PlayerDto): void {
- upsertPlayer.run(playerToRow(player))
- },
- upsertMany: db.transaction((list: readonly PlayerDto[]) => {
- for (const p of list) upsertPlayer.run(playerToRow(p))
- }) as (list: readonly PlayerDto[]) => void,
- resetAllPositions(pos: [number, number, number], yaw: number): void {
- db.prepare('UPDATE players SET pos_x = ?, pos_y = ?, pos_z = ?, yaw = ?').run(
- pos[0],
- pos[1],
- pos[2],
- yaw,
- )
- },
- }
-
- const upsertConstraint = db.prepare(`
- INSERT INTO constraints (id, type, entity_a, entity_b, params, updated_at)
- VALUES (@id, @type, @entity_a, @entity_b, @params, @updated_at)
- ON CONFLICT(id) DO UPDATE SET params=excluded.params, updated_at=excluded.updated_at
- `)
- const deleteConstraint = db.prepare('DELETE FROM constraints WHERE id = ?')
- const selectConstraints = db.prepare<[], ConstraintRow>('SELECT * FROM constraints')
-
- const constraints: ConstraintRepository = {
- loadAll(): ConstraintDto[] {
- return selectConstraints.all().map((row) => ({
- id: row.id,
- type: row.type,
- entityA: row.entity_a,
- entityB: row.entity_b,
- params: row.params ? (JSON.parse(row.params) as Record<string, unknown>) : null,
- updatedAt: row.updated_at,
- }))
- },
- upsertMany: db.transaction((list: readonly ConstraintDto[]) => {
- for (const c of list) {
- upsertConstraint.run({
- id: c.id,
- type: c.type,
- entity_a: c.entityA,
- entity_b: c.entityB,
- params: c.params ? JSON.stringify(c.params) : null,
- updated_at: c.updatedAt,
- })
- }
- }) as (list: readonly ConstraintDto[]) => void,
- deleteMany: db.transaction((ids: readonly string[]) => {
- for (const id of ids) deleteConstraint.run(id)
- }) as (ids: readonly string[]) => void,
- }
-
- const meta: MetaRepository = {
- get(key: string): string | null {
- return metaGet.get(key)?.value ?? null
- },
- set(key: string, value: string): void {
- metaSet.run(key, value)
- },
- }
-
- return {
- db,
- worldEntities,
- players,
- constraints,
- meta,
- identity: createIdentityRepository(db),
- mods: createModRepository(db),
- mediaMirrors: createMediaMirrorRepository(db),
- transaction<T>(fn: () => T): T {
- return db.transaction(fn)()
- },
- close(): void {
- db.close()
- },
- }
-}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+import { PGlite } from '@electric-sql/pglite'
+import { createDb, type Db } from 'openvibe-sdk/db'
+import { openPgStore, type PgPersistenceStore } from './pg/pgStore.js'
+import { MIGRATIONS_DIR } from './migrations.js'
+
+/**
+ * Test helpers: one in-memory PGlite database per test process, with the Games migrations applied
+ * once, so what the tests exercise is the real PostgreSQL schema and the real openvibe-sdk/db layer.
+ * Starting PGlite (WASM) is expensive, so the instance is shared and emptied between tests instead of
+ * recreated; each `openTestStore()` sees an empty schema.
+ */
+export { MIGRATIONS_DIR }
+
+const TABLES = [
+ 'places',
+ 'world_meta',
+ 'world_entities',
+ 'world_constraints',
+ 'characters',
+ 'mods',
+ 'mod_grants',
+ 'mod_placements',
+ 'mod_audit',
+ 'media_mirrors',
+ 'identity_adoptions',
+ 'account_data_events',
+ 'event_outbox',
+ 'token_revocations',
+].join(', ')
+
+let shared: Db | null = null
+
+async function ensureDb(): Promise<Db> {
+ if (!shared) {
+ const pg = new PGlite()
+ const db = createDb({ pglite: pg, service: 'games-test' })
+ await db.migrate({ dir: MIGRATIONS_DIR })
+ shared = db
+ }
+ await shared.query(`TRUNCATE ${TABLES} RESTART IDENTITY CASCADE`)
+ return shared
+}
+
+/** The shared, freshly emptied in-memory database (do not close it). */
+export async function openTestDb(): Promise<Db> {
+ return ensureDb()
+}
+
+/** A store over the shared, freshly emptied database; its `close()` is a no-op (the instance is shared). */
+export async function openTestStore(opts: { placeId?: string } = {}): Promise<PgPersistenceStore> {
+ const db = await ensureDb()
+ const store = openPgStore(db, opts)
+ await store.ensurePlace()
+ return { ...store, close: async () => {} }
+}
@@ -1,11 +1,10 @@
@@ -1,11 +1,10 @@
lockfileVersion: '9.0'
lockfileVersion: '9.0'
settings:
settings:
- autoInstallPeers: true
+ autoInstallPeers: false
excludeLinksFromLockfile: false
excludeLinksFromLockfile: false
importers:
importers:
-
.:
.:
devDependencies:
devDependencies:
'@eslint/js':
'@eslint/js':
@@ -87,6 +86,9 @@ importers:
@@ -87,6 +86,9 @@ importers:
'@babylonjs/havok':
'@babylonjs/havok':
specifier: ^1.3.10
specifier: ^1.3.10
version: 1.3.14
version: 1.3.14
+ '@electric-sql/pglite':
+ specifier: ^0.5.8
+ version: 0.5.8
'@openvibe/content':
'@openvibe/content':
specifier: workspace:*
specifier: workspace:*
version: link:../../packages/content
version: link:../../packages/content
@@ -108,18 +110,21 @@ importers:
@@ -108,18 +110,21 @@ importers:
ajv:
ajv:
specifier: ^8.17.1
specifier: ^8.17.1
version: 8.20.0
version: 8.20.0
- better-sqlite3:
- specifier: ^11.9.1
- version: 11.10.0
+ iovalkey:
+ specifier: ^0.4.0
+ version: 0.4.0
openvibe-contracts:
openvibe-contracts:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
openvibe-sdk:
openvibe-sdk:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected])
openvibe-shared:
openvibe-shared:
- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
- version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
+ version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1
+ pg:
+ specifier: ^8.23.0
+ version: 8.23.0
tsx:
tsx:
specifier: ^4.19.3
specifier: ^4.19.3
version: 4.23.11
version: 4.23.11
@@ -127,9 +132,6 @@ importers:
@@ -127,9 +132,6 @@ importers:
specifier: ^8.18.1
specifier: ^8.18.1
version: 8.21.3
version: 8.21.3
devDependencies:
devDependencies:
- '@types/better-sqlite3':
- specifier: ^7.6.12
- version: 7.6.13
'@types/ws':
'@types/ws':
specifier: ^8.18.0
specifier: ^8.18.0
version: 8.18.1
version: 8.18.1
@@ -157,6 +159,9 @@ importers:
@@ -157,6 +159,9 @@ importers:
packages/persistence:
packages/persistence:
dependencies:
dependencies:
+ '@electric-sql/pglite':
+ specifier: ^0.5.8
+ version: 0.5.8
'@openvibe/gameplay':
'@openvibe/gameplay':
specifier: workspace:*
specifier: workspace:*
version: link:../gameplay
version: link:../gameplay
@@ -166,13 +171,9 @@ importers:
@@ -166,13 +171,9 @@ importers:
'@openvibe/shared':
'@openvibe/shared':
specifier: workspace:*
specifier: workspace:*
version: link:../shared
version: link:../shared
- better-sqlite3:
- specifier: ^11.9.1
- version: 11.10.0
- devDependencies:
- '@types/better-sqlite3':
- specifier: ^7.6.12
- version: 7.6.13
+ openvibe-sdk:
+ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0
+ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected])
packages/physics:
packages/physics:
dependencies:
dependencies:
@@ -198,72 +199,113 @@ importers:
@@ -198,72 +199,113 @@ importers:
packages/shared: {}
packages/shared: {}
packages:
packages:
-
'@asamuzakjp/[email protected]':
'@asamuzakjp/[email protected]':
- resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
- engines: {node: ^22.13.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==,
+ }
+ engines: { node: ^22.13.0 || >=24.0.0 }
'@asamuzakjp/[email protected]':
'@asamuzakjp/[email protected]':
- resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
- engines: {node: ^22.13.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==,
+ }
+ engines: { node: ^22.13.0 || >=24.0.0 }
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-TczrCENrG+ww09LcIpdXG2OPU8TE4Bh8cMi5nZQdiIXexpjvDoI5/BkRhkEz/Qczdn/3sQPyLxVMNR1uho8vMA==}
+ resolution:
+ {
+ integrity: sha512-TczrCENrG+ww09LcIpdXG2OPU8TE4Bh8cMi5nZQdiIXexpjvDoI5/BkRhkEz/Qczdn/3sQPyLxVMNR1uho8vMA==,
+ }
peerDependencies:
peerDependencies:
'@babylonjs/core': ^9.0.0
'@babylonjs/core': ^9.0.0
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==}
+ resolution:
+ {
+ integrity: sha512-iWM/Re+FnqTEOxkGxvjRjChcPZzydcb0OGtnVk5o0vDQizqwkcpWFlJzfIv1Ij7Li2edJJJDZd6jRzC8QSWuuw==,
+ }
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==}
+ resolution:
+ {
+ integrity: sha512-+j2FDwLtkh5Gb//KizE8Ks3GIB/3vH8Yx9cQsMvZQpsKkH5gn089wIlJ9Yt8dJxbwBmWKGpMTIXxZE7YdOHUnQ==,
+ }
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==}
+ resolution:
+ {
+ integrity: sha512-zGu6SRvN4uzk5KF0exoSjBZbBcvkEz6iteSvgCn4EP4nQD60W2D4WCtF8m2usZ/yALUOQE5EQON6Tuq1I4LWXg==,
+ }
peerDependencies:
peerDependencies:
'@babylonjs/core': ^9.0.0
'@babylonjs/core': ^9.0.0
babylonjs-gltf2interface: ^9.0.0
babylonjs-gltf2interface: ^9.0.0
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-eHs6dn+kasWufTUC2EcrXaTTkWxbGDW0d7U1SyrsUc+ldYb4ktCb9taaoxooGhvLdmrCfd8GaqLvCSBf71Y0fw==}
+ resolution:
+ {
+ integrity: sha512-eHs6dn+kasWufTUC2EcrXaTTkWxbGDW0d7U1SyrsUc+ldYb4ktCb9taaoxooGhvLdmrCfd8GaqLvCSBf71Y0fw==,
+ }
peerDependencies:
peerDependencies:
'@babylonjs/core': ^9.0.0
'@babylonjs/core': ^9.0.0
'@babylonjs/[email protected]':
'@babylonjs/[email protected]':
- resolution: {integrity: sha512-ABrxLCVnB/xkq+Sgw7r+0qs9J8QJr4fit0YI6eysqTNUeP751Ob/1VAlxLllGN7nG5cIlCVP152/t3VanxXgzQ==}
+ resolution:
+ {
+ integrity: sha512-ABrxLCVnB/xkq+Sgw7r+0qs9J8QJr4fit0YI6eysqTNUeP751Ob/1VAlxLllGN7nG5cIlCVP152/t3VanxXgzQ==,
+ }
peerDependencies:
peerDependencies:
'@babylonjs/core': ^9.0.0
'@babylonjs/core': ^9.0.0
'@bramus/[email protected]':
'@bramus/[email protected]':
- resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ resolution:
+ {
+ integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==,
+ }
hasBin: true
hasBin: true
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==,
+ }
+ engines: { node: '>=20.19.0' }
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==,
+ }
+ engines: { node: '>=20.19.0' }
peerDependencies:
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==,
+ }
+ engines: { node: '>=20.19.0' }
peerDependencies:
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==,
+ }
+ engines: { node: '>=20.19.0' }
peerDependencies:
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
+ resolution:
+ {
+ integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==,
+ }
peerDependencies:
peerDependencies:
css-tree: ^3.2.1
css-tree: ^3.2.1
peerDependenciesMeta:
peerDependenciesMeta:
@@ -271,362 +313,557 @@ packages:
@@ -271,362 +313,557 @@ packages:
optional: true
optional: true
'@csstools/[email protected]':
'@csstools/[email protected]':
- resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==,
+ }
+ engines: { node: '>=20.19.0' }
+
+ '@electric-sql/[email protected]':
+ resolution:
+ {
+ integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==,
+ }
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==,
+ }
+ engines: { node: '>=18' }
cpu: [ppc64]
cpu: [ppc64]
os: [aix]
os: [aix]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==,
+ }
+ engines: { node: '>=18' }
cpu: [ppc64]
cpu: [ppc64]
os: [aix]
os: [aix]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm]
cpu: [arm]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm]
cpu: [arm]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [android]
os: [android]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [darwin]
os: [darwin]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [darwin]
os: [darwin]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [darwin]
os: [darwin]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [darwin]
os: [darwin]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [freebsd]
os: [freebsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [freebsd]
os: [freebsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [freebsd]
os: [freebsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [freebsd]
os: [freebsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==,
+ }
+ engines: { node: '>=18' }
cpu: [arm]
cpu: [arm]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==,
+ }
+ engines: { node: '>=18' }
cpu: [arm]
cpu: [arm]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==,
+ }
+ engines: { node: '>=18' }
cpu: [ia32]
cpu: [ia32]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==,
+ }
+ engines: { node: '>=18' }
cpu: [ia32]
cpu: [ia32]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==,
+ }
+ engines: { node: '>=18' }
cpu: [loong64]
cpu: [loong64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==,
+ }
+ engines: { node: '>=18' }
cpu: [loong64]
cpu: [loong64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==,
+ }
+ engines: { node: '>=18' }
cpu: [mips64el]
cpu: [mips64el]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==,
+ }
+ engines: { node: '>=18' }
cpu: [mips64el]
cpu: [mips64el]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==,
+ }
+ engines: { node: '>=18' }
cpu: [ppc64]
cpu: [ppc64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==,
+ }
+ engines: { node: '>=18' }
cpu: [ppc64]
cpu: [ppc64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==,
+ }
+ engines: { node: '>=18' }
cpu: [riscv64]
cpu: [riscv64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==,
+ }
+ engines: { node: '>=18' }
cpu: [riscv64]
cpu: [riscv64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==,
+ }
+ engines: { node: '>=18' }
cpu: [s390x]
cpu: [s390x]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==,
+ }
+ engines: { node: '>=18' }
cpu: [s390x]
cpu: [s390x]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [linux]
os: [linux]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [netbsd]
os: [netbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [netbsd]
os: [netbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [netbsd]
os: [netbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [netbsd]
os: [netbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [openbsd]
os: [openbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [openbsd]
os: [openbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [openbsd]
os: [openbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [openbsd]
os: [openbsd]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [openharmony]
os: [openharmony]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [openharmony]
os: [openharmony]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [sunos]
os: [sunos]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [sunos]
os: [sunos]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [win32]
os: [win32]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==,
+ }
+ engines: { node: '>=18' }
cpu: [arm64]
cpu: [arm64]
os: [win32]
os: [win32]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==,
+ }
+ engines: { node: '>=18' }
cpu: [ia32]
cpu: [ia32]
os: [win32]
os: [win32]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==,
+ }
+ engines: { node: '>=18' }
cpu: [ia32]
cpu: [ia32]
os: [win32]
os: [win32]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [win32]
os: [win32]
'@esbuild/[email protected]':
'@esbuild/[email protected]':
- resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==,
+ }
+ engines: { node: '>=18' }
cpu: [x64]
cpu: [x64]
os: [win32]
os: [win32]
'@eslint-community/[email protected]':
'@eslint-community/[email protected]':
- resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ resolution:
+ {
+ integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==,
+ }
+ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }
peerDependencies:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/[email protected]':
'@eslint-community/[email protected]':
- resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
- engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+ resolution:
+ {
+ integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==,
+ }
+ engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@eslint/[email protected]':
'@eslint/[email protected]':
- resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@exodus/[email protected]':
'@exodus/[email protected]':
- resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==,
+ }
+ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }
peerDependencies:
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
peerDependenciesMeta:
@@ -634,247 +871,403 @@ packages:
@@ -634,247 +871,403 @@ packages:
optional: true
optional: true
'@humanfs/[email protected]':
'@humanfs/[email protected]':
- resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
- engines: {node: '>=18.18.0'}
+ resolution:
+ {
+ integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==,
+ }
+ engines: { node: '>=18.18.0' }
'@humanfs/[email protected]':
'@humanfs/[email protected]':
- resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
- engines: {node: '>=18.18.0'}
+ resolution:
+ {
+ integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==,
+ }
+ engines: { node: '>=18.18.0' }
'@humanfs/[email protected]':
'@humanfs/[email protected]':
- resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
- engines: {node: '>=18.18.0'}
+ resolution:
+ {
+ integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==,
+ }
+ engines: { node: '>=18.18.0' }
'@humanwhocodes/[email protected]':
'@humanwhocodes/[email protected]':
- resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
- engines: {node: '>=12.22'}
+ resolution:
+ {
+ integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==,
+ }
+ engines: { node: '>=12.22' }
'@humanwhocodes/[email protected]':
'@humanwhocodes/[email protected]':
- resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
- engines: {node: '>=18.18'}
+ resolution:
+ {
+ integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==,
+ }
+ engines: { node: '>=18.18' }
+
+ '@iovalkey/[email protected]':
+ resolution:
+ {
+ integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==,
+ }
'@jridgewell/[email protected]':
'@jridgewell/[email protected]':
- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ resolution:
+ {
+ integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==,
+ }
'@napi-rs/[email protected]':
'@napi-rs/[email protected]':
- resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
- engines: {node: ^22.20 || ^24.12 || >=25}
+ resolution:
+ {
+ integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==,
+ }
+ engines: { node: ^22.20 || ^24.12 || >=25 }
cpu: [x64]
cpu: [x64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
+ resolution:
+ {
+ integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==,
+ }
cpu: [arm]
cpu: [arm]
os: [android]
os: [android]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}
+ resolution:
+ {
+ integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [android]
os: [android]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
+ resolution:
+ {
+ integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [darwin]
os: [darwin]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}
+ resolution:
+ {
+ integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==,
+ }
cpu: [x64]
cpu: [x64]
os: [darwin]
os: [darwin]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}
+ resolution:
+ {
+ integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [freebsd]
os: [freebsd]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}
+ resolution:
+ {
+ integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==,
+ }
cpu: [x64]
cpu: [x64]
os: [freebsd]
os: [freebsd]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
+ resolution:
+ {
+ integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==,
+ }
cpu: [arm]
cpu: [arm]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
+ resolution:
+ {
+ integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==,
+ }
cpu: [arm]
cpu: [arm]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
+ resolution:
+ {
+ integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
+ resolution:
+ {
+ integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
+ resolution:
+ {
+ integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==,
+ }
cpu: [loong64]
cpu: [loong64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
+ resolution:
+ {
+ integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==,
+ }
cpu: [loong64]
cpu: [loong64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
+ resolution:
+ {
+ integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==,
+ }
cpu: [ppc64]
cpu: [ppc64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
+ resolution:
+ {
+ integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==,
+ }
cpu: [ppc64]
cpu: [ppc64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
+ resolution:
+ {
+ integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==,
+ }
cpu: [riscv64]
cpu: [riscv64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
+ resolution:
+ {
+ integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==,
+ }
cpu: [riscv64]
cpu: [riscv64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
+ resolution:
+ {
+ integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==,
+ }
cpu: [s390x]
cpu: [s390x]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
+ resolution:
+ {
+ integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==,
+ }
cpu: [x64]
cpu: [x64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
+ resolution:
+ {
+ integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==,
+ }
cpu: [x64]
cpu: [x64]
os: [linux]
os: [linux]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
+ resolution:
+ {
+ integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==,
+ }
cpu: [x64]
cpu: [x64]
os: [openbsd]
os: [openbsd]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}
+ resolution:
+ {
+ integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [openharmony]
os: [openharmony]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}
+ resolution:
+ {
+ integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==,
+ }
cpu: [arm64]
cpu: [arm64]
os: [win32]
os: [win32]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}
+ resolution:
+ {
+ integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==,
+ }
cpu: [ia32]
cpu: [ia32]
os: [win32]
os: [win32]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}
+ resolution:
+ {
+ integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==,
+ }
cpu: [x64]
cpu: [x64]
os: [win32]
os: [win32]
'@rollup/[email protected]':
'@rollup/[email protected]':
- resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}
+ resolution:
+ {
+ integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==,
+ }
cpu: [x64]
cpu: [x64]
os: [win32]
os: [win32]
- '@types/[email protected]':
- resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
-
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ resolution:
+ {
+ integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ resolution:
+ {
+ integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
+ resolution:
+ {
+ integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ resolution:
+ {
+ integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ resolution:
+ {
+ integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+ resolution:
+ {
+ integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==,
+ }
'@types/[email protected]':
'@types/[email protected]':
- resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ resolution:
+ {
+ integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==,
+ }
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
'@typescript-eslint/parser': ^8.66.0
'@typescript-eslint/parser': ^8.66.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected]':
'@typescript-eslint/[email protected]':
- resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
+ resolution:
+ {
+ integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==,
+ }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
+ resolution:
+ {
+ integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==,
+ }
peerDependencies:
peerDependencies:
msw: ^2.4.9
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
@@ -885,32 +1278,56 @@ packages:
@@ -885,32 +1278,56 @@ packages:
optional: true
optional: true
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
+ resolution:
+ {
+ integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==,
+ }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
+ resolution:
+ {
+ integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==,
+ }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
+ resolution:
+ {
+ integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==,
+ }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
+ resolution:
+ {
+ integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==,
+ }
'@vitest/[email protected]':
'@vitest/[email protected]':
- resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
+ resolution:
+ {
+ integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==,
+ }
- resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ resolution:
+ {
+ integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==,
+ }
peerDependencies:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
- resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
- engines: {node: '>=0.4.0'}
+ resolution:
+ {
+ integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==,
+ }
+ engines: { node: '>=0.4.0' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ resolution:
+ {
+ integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==,
+ }
peerDependencies:
peerDependencies:
ajv: ^8.0.0
ajv: ^8.0.0
peerDependenciesMeta:
peerDependenciesMeta:
@@ -918,108 +1335,169 @@ packages:
@@ -918,108 +1335,169 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+ resolution:
+ {
+ integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==,
+ }
- resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+ resolution:
+ {
+ integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==,
+ }
- resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ resolution:
+ {
+ integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==,
+ }
- resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
- engines: {node: '>=12'}
+ resolution:
+ {
+ integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==,
+ }
+ engines: { node: '>=12' }
- resolution: {integrity: sha512-BxqokOP3obVy32CZ9ZV8VQqrVvodiBvk6e3T0Eg9PHM2i6rFXtpXeSEX3isNyPVotonLdBkpeTk2RvRqYIQMEg==}
+ resolution:
+ {
+ integrity: sha512-BxqokOP3obVy32CZ9ZV8VQqrVvodiBvk6e3T0Eg9PHM2i6rFXtpXeSEX3isNyPVotonLdBkpeTk2RvRqYIQMEg==,
+ }
- resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ resolution:
+ {
+ integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==,
+ }
- resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
- engines: {node: 18 || 20 || >=22}
-
- resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
-
- resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
+ resolution:
+ {
+ integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==,
+ }
+ engines: { node: 18 || 20 || >=22 }
- resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
-
- resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
-
- resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ resolution:
+ {
+ integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==,
+ }
- resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
+ resolution:
+ {
+ integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==,
+ }
- resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
- engines: {node: 20 || >=22}
+ resolution:
+ {
+ integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==,
+ }
+ engines: { node: 20 || >=22 }
- resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
-
- resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+ resolution:
+ {
+ integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==,
+ }
- resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
- engines: {node: '>=6'}
+ resolution:
+ {
+ integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==,
+ }
+ engines: { node: '>=6' }
- resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==,
+ }
+ engines: { node: '>=18' }
- resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
- engines: {node: '>= 16'}
-
- resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+ resolution:
+ {
+ integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==,
+ }
+ engines: { node: '>= 16' }
+
+ resolution:
+ {
+ integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
- engines: {node: '>=7.0.0'}
+ resolution:
+ {
+ integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==,
+ }
+ engines: { node: '>=7.0.0' }
- resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ resolution:
+ {
+ integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,
+ }
- resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ resolution:
+ {
+ integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==,
+ }
- resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
- engines: {node: '>= 8'}
+ resolution:
+ {
+ integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==,
+ }
+ engines: { node: '>= 8' }
- resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
- engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ resolution:
+ {
+ integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==,
+ }
+ engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 }
- resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==,
+ }
+ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }
- resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
- engines: {node: '>=6.0'}
+ resolution:
+ {
+ integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==,
+ }
+ engines: { node: '>=6.0' }
peerDependencies:
peerDependencies:
supports-color: '*'
supports-color: '*'
peerDependenciesMeta:
peerDependenciesMeta:
@@ -1027,73 +1505,107 @@ packages:
@@ -1027,73 +1505,107 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
-
- resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==,
+ }
- resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
- engines: {node: '>=6'}
-
- resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
- engines: {node: '>=4.0.0'}
+ resolution:
+ {
+ integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==,
+ }
+ engines: { node: '>=6' }
- resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
-
- resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==,
+ }
+ engines: { node: '>=0.10' }
- resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
-
- resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+ resolution:
+ {
+ integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==,
+ }
- resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
- engines: {node: '>=20.19.0'}
+ resolution:
+ {
+ integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==,
+ }
+ engines: { node: '>=20.19.0' }
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ resolution:
+ {
+ integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==,
+ }
- resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==,
+ }
+ engines: { node: '>=18' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==,
+ }
+ engines: { node: '>=18' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
- resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ resolution:
+ {
+ integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==,
+ }
+ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }
- resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
- resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
- engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ resolution:
+ {
+ integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==,
+ }
+ engines: { node: ^20.19.0 || ^22.13.0 || >=24 }
- resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
hasBin: true
hasBin: true
peerDependencies:
peerDependencies:
jiti: '*'
jiti: '*'
@@ -1102,51 +1614,83 @@ packages:
@@ -1102,51 +1614,83 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
- resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
- engines: {node: '>=0.10'}
+ resolution:
+ {
+ integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==,
+ }
+ engines: { node: '>=0.10' }
- resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
- engines: {node: '>=4.0'}
+ resolution:
+ {
+ integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==,
+ }
+ engines: { node: '>=4.0' }
- resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
- engines: {node: '>=4.0'}
+ resolution:
+ {
+ integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==,
+ }
+ engines: { node: '>=4.0' }
- resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+ resolution:
+ {
+ integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==,
+ }
- resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
- engines: {node: '>=0.10.0'}
-
- resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
- engines: {node: '>=6'}
+ resolution:
+ {
+ integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
- engines: {node: '>=12.0.0'}
+ resolution:
+ {
+ integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==,
+ }
+ engines: { node: '>=12.0.0' }
- resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ resolution:
+ {
+ integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
+ }
- resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+ resolution:
+ {
+ integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==,
+ }
- resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+ resolution:
+ {
+ integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==,
+ }
- resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
+ resolution:
+ {
+ integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==,
+ }
- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
- engines: {node: '>=12.0.0'}
+ resolution:
+ {
+ integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==,
+ }
+ engines: { node: '>=12.0.0' }
peerDependencies:
peerDependencies:
picomatch: ^3 || ^4
picomatch: ^3 || ^4
peerDependenciesMeta:
peerDependenciesMeta:
@@ -1154,104 +1698,156 @@ packages:
@@ -1154,104 +1698,156 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
- engines: {node: '>=16.0.0'}
-
- resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
+ resolution:
+ {
+ integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==,
+ }
+ engines: { node: '>=16.0.0' }
- resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
- engines: {node: '>=16'}
+ resolution:
+ {
+ integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==,
+ }
+ engines: { node: '>=16' }
- resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
-
- resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+ resolution:
+ {
+ integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==,
+ }
- resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ resolution:
+ {
+ integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==,
+ }
+ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }
os: [darwin]
os: [darwin]
- resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ resolution:
+ {
+ integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==,
+ }
+ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }
os: [darwin]
os: [darwin]
- resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
-
- resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
- engines: {node: '>=10.13.0'}
+ resolution:
+ {
+ integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==,
+ }
+ engines: { node: '>=10.13.0' }
- resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==,
+ }
+ engines: { node: '>=18' }
- resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
-
- resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+ resolution:
+ {
+ integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==,
+ }
+ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }
- resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
- engines: {node: '>= 4'}
+ resolution:
+ {
+ integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==,
+ }
+ engines: { node: '>= 4' }
- resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
- engines: {node: '>= 4'}
+ resolution:
+ {
+ integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==,
+ }
+ engines: { node: '>= 4' }
- resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
- engines: {node: '>=6'}
+ resolution:
+ {
+ integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==,
+ }
+ engines: { node: '>=6' }
- resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
- engines: {node: '>=0.8.19'}
-
- resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
-
- resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+ resolution:
+ {
+ integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==,
+ }
+ engines: { node: '>=0.8.19' }
+
+ resolution:
+ {
+ integrity: sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==,
+ }
+ engines: { node: '>=18.12.0' }
- resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
- engines: {node: '>=0.10.0'}
+ resolution:
+ {
+ integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
- engines: {node: '>=0.10.0'}
+ resolution:
+ {
+ integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ resolution:
+ {
+ integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==,
+ }
- resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ resolution:
+ {
+ integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==,
+ }
- resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+ resolution:
+ {
+ integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==,
+ }
- resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
+ resolution:
+ {
+ integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==,
+ }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
- engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ resolution:
+ {
+ integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==,
+ }
+ engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 }
peerDependencies:
peerDependencies:
canvas: ^3.2.3
canvas: ^3.2.3
peerDependenciesMeta:
peerDependenciesMeta:
@@ -1259,381 +1855,702 @@ packages:
@@ -1259,381 +1855,702 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+ resolution:
+ {
+ integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==,
+ }
- resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+ resolution:
+ {
+ integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==,
+ }
- resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ resolution:
+ {
+ integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==,
+ }
- resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ resolution:
+ {
+ integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==,
+ }
- resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
- engines: {node: '>=12', npm: '>=6'}
+ resolution:
+ {
+ integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==,
+ }
+ engines: { node: '>=12', npm: '>=6' }
- resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+ resolution:
+ {
+ integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==,
+ }
- resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+ resolution:
+ {
+ integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==,
+ }
- resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ resolution:
+ {
+ integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==,
+ }
- resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
- engines: {node: '>= 0.8.0'}
+ resolution:
+ {
+ integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==,
+ }
+ engines: { node: '>= 0.8.0' }
- resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==,
+ }
+ engines: { node: '>=10' }
+
+ resolution:
+ {
+ integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==,
+ }
- resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+ resolution:
+ {
+ integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==,
+ }
- resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+ resolution:
+ {
+ integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==,
+ }
- resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+ resolution:
+ {
+ integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==,
+ }
- resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+ resolution:
+ {
+ integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==,
+ }
- resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+ resolution:
+ {
+ integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==,
+ }
- resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+ resolution:
+ {
+ integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==,
+ }
- resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ resolution:
+ {
+ integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
+ }
- resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+ resolution:
+ {
+ integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==,
+ }
- resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ resolution:
+ {
+ integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==,
+ }
- resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
- engines: {node: 20 || >=22}
+ resolution:
+ {
+ integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==,
+ }
+ engines: { node: 20 || >=22 }
- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ resolution:
+ {
+ integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==,
+ }
- resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
-
- resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==,
+ }
- resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
- engines: {node: 18 || 20 || >=22}
+ resolution:
+ {
+ integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==,
+ }
+ engines: { node: 18 || 20 || >=22 }
- resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
-
- resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
-
- resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+ resolution:
+ {
+ integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==,
+ }
- resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ resolution:
+ {
+ integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==,
+ }
- resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ resolution:
+ {
+ integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==,
+ }
+ engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
-
- resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
-
- resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==}
- engines: {node: '>=10'}
-
- resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ resolution:
+ {
+ integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==,
+ }
openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0}
+ resolution:
+ {
+ tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0,
+ }
version: 0.71.0
version: 0.71.0
- engines: {node: '>=20'}
+ engines: { node: '>=20' }
hasBin: true
hasBin: true
- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0}
- version: 0.12.0
- engines: {node: '>=20'}
+ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0:
+ resolution:
+ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0 }
+ version: 0.22.0
+ engines: { node: '>=20' }
+ peerDependencies:
+ '@electric-sql/pglite': ^0.5.8
+ better-sqlite3: '>=11'
+ iovalkey: ^0.4.0
+ pg: ^8.23.0
+ peerDependenciesMeta:
+ '@electric-sql/pglite':
+ optional: true
+ better-sqlite3:
+ optional: true
+ iovalkey:
+ optional: true
+ pg:
+ optional: true
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0}
- version: 1.23.0
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
+ resolution:
+ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1 }
+ version: 1.30.1
- resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
- engines: {node: '>= 0.8.0'}
+ resolution:
+ {
+ integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==,
+ }
+ engines: { node: '>= 0.8.0' }
- resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
- engines: {node: '>=6'}
+ resolution:
+ {
+ integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==,
+ }
+ engines: { node: '>=6' }
- resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+ resolution:
+ {
+ integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==,
+ }
- resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ resolution:
+ {
+ integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==,
+ }
- resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
- engines: {node: '>= 14.16'}
+ resolution:
+ {
+ integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==,
+ }
+ engines: { node: '>= 14.16' }
+
+ resolution:
+ {
+ integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==,
+ }
+ engines: { node: '>=4.0.0' }
+
+ resolution:
+ {
+ integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==,
+ }
+ peerDependencies:
+ pg: '>=8.0'
+
+ resolution:
+ {
+ integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==,
+ }
+ engines: { node: '>=4' }
+
+ resolution:
+ {
+ integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==,
+ }
+ engines: { node: '>= 16.0.0' }
+ peerDependencies:
+ pg-native: '>=3.0.1'
+ peerDependenciesMeta:
+ pg-native:
+ optional: true
+
+ resolution:
+ {
+ integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==,
+ }
- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+ resolution:
+ {
+ integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==,
+ }
- resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
- engines: {node: '>=12'}
+ resolution:
+ {
+ integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==,
+ }
+ engines: { node: '>=12' }
- resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
- engines: {node: '>=20'}
+ resolution:
+ {
+ integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==,
+ }
+ engines: { node: '>=20' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
- engines: {node: '>=20'}
+ resolution:
+ {
+ integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==,
+ }
+ engines: { node: '>=20' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
- engines: {node: ^10 || ^12 || >=14}
-
- resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
- engines: {node: '>=10'}
- deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
- hasBin: true
+ resolution:
+ {
+ integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==,
+ }
+ engines: { node: ^10 || ^12 || >=14 }
+
+ resolution:
+ {
+ integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==,
+ }
+ engines: { node: '>=4' }
+
+ resolution:
+ {
+ integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==,
+ }
+ engines: { node: '>=0.10.0' }
+
+ resolution:
+ {
+ integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==,
+ }
+ engines: { node: '>=0.10.0' }
+
+ resolution:
+ {
+ integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
- engines: {node: '>= 0.8.0'}
+ resolution:
+ {
+ integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==,
+ }
+ engines: { node: '>= 0.8.0' }
- resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
- engines: {node: '>=14'}
+ resolution:
+ {
+ integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==,
+ }
+ engines: { node: '>=14' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
-
- resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
- engines: {node: '>=6'}
-
- resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
- hasBin: true
-
- resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
- engines: {node: '>= 6'}
+ resolution:
+ {
+ integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==,
+ }
+ engines: { node: '>=6' }
+
+ resolution:
+ {
+ integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==,
+ }
+ engines: { node: '>=4' }
+
+ resolution:
+ {
+ integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==,
+ }
+ engines: { node: '>=4' }
- resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
- engines: {node: '>=0.10.0'}
+ resolution:
+ {
+ integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
- engines: {node: '>=4'}
+ resolution:
+ {
+ integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==,
+ }
+ engines: { node: '>=4' }
- resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
- engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ resolution:
+ {
+ integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==,
+ }
+ engines: { node: '>=18.0.0', npm: '>=8.0.0' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ resolution:
+ {
+ integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==,
+ }
- resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
- engines: {node: '>=v12.22.7'}
+ resolution:
+ {
+ integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==,
+ }
+ engines: { node: '>=v12.22.7' }
- resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==,
+ }
+ engines: { node: '>=10' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
-
- resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
-
- resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+ resolution:
+ {
+ integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==,
+ }
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
- engines: {node: '>=0.10.0'}
+ resolution:
+ {
+ integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==,
+ }
+ engines: { node: '>=0.10.0' }
+
+ resolution:
+ {
+ integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==,
+ }
+ engines: { node: '>= 10.x' }
- resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
-
- resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ resolution:
+ {
+ integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==,
+ }
- resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ resolution:
+ {
+ integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==,
+ }
- resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
- engines: {node: '>=0.10.0'}
+ resolution:
+ {
+ integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==,
+ }
- resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+ resolution:
+ {
+ integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==,
+ }
- resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==,
+ }
+ engines: { node: '>=8' }
- resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
-
- resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
-
- resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
- engines: {node: '>=6'}
+ resolution:
+ {
+ integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==,
+ }
- resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+ resolution:
+ {
+ integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==,
+ }
- resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+ resolution:
+ {
+ integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==,
+ }
- resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
- engines: {node: '>=12.0.0'}
+ resolution:
+ {
+ integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==,
+ }
+ engines: { node: '>=12.0.0' }
- resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
- engines: {node: ^18.0.0 || >=20.0.0}
+ resolution:
+ {
+ integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==,
+ }
+ engines: { node: ^18.0.0 || >=20.0.0 }
- resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
- engines: {node: '>=14.0.0'}
+ resolution:
+ {
+ integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==,
+ }
+ engines: { node: '>=14.0.0' }
- resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
- engines: {node: '>=14.0.0'}
+ resolution:
+ {
+ integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==,
+ }
+ engines: { node: '>=14.0.0' }
- resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
+ resolution:
+ {
+ integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==,
+ }
- resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
+ resolution:
+ {
+ integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==,
+ }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
- engines: {node: '>=16'}
+ resolution:
+ {
+ integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==,
+ }
+ engines: { node: '>=16' }
- resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
- engines: {node: '>=20'}
+ resolution:
+ {
+ integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==,
+ }
+ engines: { node: '>=20' }
- resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
- engines: {node: '>=18.12'}
+ resolution:
+ {
+ integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==,
+ }
+ engines: { node: '>=18.12' }
peerDependencies:
peerDependencies:
typescript: '>=4.8.4'
typescript: '>=4.8.4'
- resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==}
- engines: {node: '>=18.0.0'}
+ resolution:
+ {
+ integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==,
+ }
+ engines: { node: '>=18.0.0' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
-
- resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
- engines: {node: '>= 0.8.0'}
+ resolution:
+ {
+ integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==,
+ }
+ engines: { node: '>= 0.8.0' }
- resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ resolution:
+ {
+ integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==,
+ }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
peerDependencies:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript: '>=4.8.4 <6.1.0'
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
+ resolution:
+ {
+ integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==,
+ }
+ engines: { node: '>=14.17' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ resolution:
+ {
+ integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==,
+ }
- resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
- engines: {node: '>=22.19.0'}
+ resolution:
+ {
+ integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==,
+ }
+ engines: { node: '>=22.19.0' }
- resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
-
- resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ resolution:
+ {
+ integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==,
+ }
- resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ resolution:
+ {
+ integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==,
+ }
+ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ resolution:
+ {
+ integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==,
+ }
+ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }
hasBin: true
hasBin: true
peerDependencies:
peerDependencies:
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
@@ -1672,8 +2589,11 @@ packages:
@@ -1672,8 +2589,11 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ resolution:
+ {
+ integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==,
+ }
+ engines: { node: ^20.19.0 || >=22.12.0 }
hasBin: true
hasBin: true
peerDependencies:
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@types/node': ^20.19.0 || >=22.12.0
@@ -1712,8 +2632,11 @@ packages:
@@ -1712,8 +2632,11 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ resolution:
+ {
+ integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==,
+ }
+ engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 }
hasBin: true
hasBin: true
peerDependencies:
peerDependencies:
'@edge-runtime/vm': '*'
'@edge-runtime/vm': '*'
@@ -1740,45 +2663,69 @@ packages:
@@ -1740,45 +2663,69 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==,
+ }
+ engines: { node: '>=18' }
- resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
- engines: {node: '>=20'}
+ resolution:
+ {
+ integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==,
+ }
+ engines: { node: '>=20' }
- resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
- engines: {node: '>=20'}
+ resolution:
+ {
+ integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==,
+ }
+ engines: { node: '>=20' }
- resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==,
+ }
+ engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 }
- resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
- engines: {node: ^22.14.0 || >=24.0.0}
+ resolution:
+ {
+ integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==,
+ }
+ engines: { node: ^22.14.0 || >=24.0.0 }
- resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
- engines: {node: '>= 8'}
+ resolution:
+ {
+ integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==,
+ }
+ engines: { node: '>= 8' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
- engines: {node: '>=8'}
+ resolution:
+ {
+ integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==,
+ }
+ engines: { node: '>=8' }
hasBin: true
hasBin: true
- resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
- engines: {node: '>=0.10.0'}
-
- resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ resolution:
+ {
+ integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==,
+ }
+ engines: { node: '>=0.10.0' }
- resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
- engines: {node: '>=10.0.0'}
+ resolution:
+ {
+ integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==,
+ }
+ engines: { node: '>=10.0.0' }
peerDependencies:
peerDependencies:
bufferutil: ^4.0.1
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
utf-8-validate: '>=5.0.2'
@@ -1789,21 +2736,39 @@ packages:
@@ -1789,21 +2736,39 @@ packages:
optional: true
optional: true
- resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
- engines: {node: '>=18'}
+ resolution:
+ {
+ integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==,
+ }
+ engines: { node: '>=18' }
- resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ resolution:
+ {
+ integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==,
+ }
+
+ resolution:
+ {
+ integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==,
+ }
+ engines: { node: '>=0.4' }
- resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
- engines: {node: '>=10'}
+ resolution:
+ {
+ integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==,
+ }
+ engines: { node: '>=10' }
- resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ resolution:
+ {
+ integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==,
+ }
snapshots:
snapshots:
-
'@asamuzakjp/[email protected]':
'@asamuzakjp/[email protected]':
dependencies:
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
@@ -1870,6 +2835,8 @@ snapshots:
@@ -1870,6 +2835,8 @@ snapshots:
'@csstools/[email protected]': {}
'@csstools/[email protected]': {}
+ '@electric-sql/[email protected]': {}
+
'@esbuild/[email protected]':
'@esbuild/[email protected]':
optional: true
optional: true
@@ -2090,6 +3057,8 @@ snapshots:
@@ -2090,6 +3057,8 @@ snapshots:
'@humanwhocodes/[email protected]': {}
'@humanwhocodes/[email protected]': {}
+ '@iovalkey/[email protected]': {}
+
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
'@napi-rs/[email protected]':
'@napi-rs/[email protected]':
@@ -2170,10 +3139,6 @@ snapshots:
@@ -2170,10 +3139,6 @@ snapshots:
'@rollup/[email protected]':
'@rollup/[email protected]':
optional: true
optional: true
- '@types/[email protected]':
- dependencies:
- '@types/node': 22.20.1
-
'@types/[email protected]':
'@types/[email protected]':
dependencies:
dependencies:
'@types/deep-eql': 4.0.2
'@types/deep-eql': 4.0.2
@@ -2366,27 +3331,10 @@ snapshots:
@@ -2366,27 +3331,10 @@ snapshots:
- [email protected]: {}
-
- dependencies:
- bindings: 1.5.0
- prebuild-install: 7.1.3
-
dependencies:
dependencies:
require-from-string: 2.0.2
require-from-string: 2.0.2
- dependencies:
- file-uri-to-path: 1.0.0
-
- dependencies:
- buffer: 5.7.1
- inherits: 2.0.4
- readable-stream: 3.6.2
-
dependencies:
dependencies:
balanced-match: 1.0.2
balanced-match: 1.0.2
@@ -2398,11 +3346,6 @@ snapshots:
@@ -2398,11 +3346,6 @@ snapshots:
- dependencies:
- base64-js: 1.5.1
- ieee754: 1.2.1
-
@@ -2422,7 +3365,7 @@ snapshots:
@@ -2422,7 +3365,7 @@ snapshots:
- [email protected]: {}
+ [email protected]: {}
dependencies:
dependencies:
@@ -2456,26 +3399,16 @@ snapshots:
@@ -2456,26 +3399,16 @@ snapshots:
- dependencies:
- mimic-response: 3.1.0
-
- [email protected]: {}
-
- [email protected]: {}
+ [email protected]: {}
dependencies:
dependencies:
safe-buffer: 5.2.1
safe-buffer: 5.2.1
- dependencies:
- once: 1.4.0
-
@@ -2612,8 +3545,6 @@ snapshots:
@@ -2612,8 +3545,6 @@ snapshots:
- [email protected]: {}
-
@@ -2632,8 +3563,6 @@ snapshots:
@@ -2632,8 +3563,6 @@ snapshots:
dependencies:
dependencies:
flat-cache: 4.0.1
flat-cache: 4.0.1
- [email protected]: {}
-
dependencies:
dependencies:
locate-path: 6.0.0
locate-path: 6.0.0
@@ -2646,16 +3575,12 @@ snapshots:
@@ -2646,16 +3575,12 @@ snapshots:
- [email protected]: {}
-
optional: true
optional: true
optional: true
optional: true
- [email protected]: {}
-
dependencies:
dependencies:
is-glob: 4.0.3
is-glob: 4.0.3
@@ -2670,8 +3595,6 @@ snapshots:
@@ -2670,8 +3595,6 @@ snapshots:
transitivePeerDependencies:
transitivePeerDependencies:
- '@noble/hashes'
- '@noble/hashes'
- [email protected]: {}
-
@@ -2683,9 +3606,19 @@ snapshots:
@@ -2683,9 +3606,19 @@ snapshots:
- [email protected]: {}
-
- [email protected]: {}
+ dependencies:
+ '@iovalkey/commands': 0.1.0
+ cluster-key-slot: 1.1.2
+ debug: 4.4.3
+ denque: 2.1.0
+ lodash.defaults: 4.2.0
+ lodash.isarguments: 3.1.0
+ redis-errors: 1.2.0
+ redis-parser: 3.0.0
+ standard-as-callback: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
@@ -2774,8 +3707,12 @@ snapshots:
@@ -2774,8 +3707,12 @@ snapshots:
dependencies:
dependencies:
p-locate: 5.0.0
p-locate: 5.0.0
+ [email protected]: {}
+
+ [email protected]: {}
+
@@ -2800,8 +3737,6 @@ snapshots:
@@ -2800,8 +3737,6 @@ snapshots:
- [email protected]: {}
-
dependencies:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.9
@@ -2810,34 +3745,24 @@ snapshots:
@@ -2810,34 +3745,24 @@ snapshots:
dependencies:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.18
- [email protected]: {}
-
- [email protected]: {}
-
- [email protected]: {}
-
- dependencies:
- semver: 7.8.5
-
- dependencies:
- wrappy: 1.0.2
-
openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
dependencies:
dependencies:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])
- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0: {}
+ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected]):
+ optionalDependencies:
+ '@electric-sql/pglite': 0.5.8
+ iovalkey: 0.4.0
+ pg: 8.23.0
- openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
+ openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1:
dependencies:
dependencies:
jsonwebtoken: 9.0.3
jsonwebtoken: 9.0.3
@@ -2874,6 +3799,41 @@ snapshots:
@@ -2874,6 +3799,41 @@ snapshots:
+ optional: true
+
+ [email protected]: {}
+
+ [email protected]: {}
+
+ dependencies:
+ pg: 8.23.0
+
+ [email protected]: {}
+
+ dependencies:
+ pg-int8: 1.0.1
+ postgres-array: 2.0.0
+ postgres-bytea: 1.0.1
+ postgres-date: 1.0.7
+ postgres-interval: 1.2.0
+
+ dependencies:
+ pg-connection-string: 2.14.0
+ pg-pool: 3.14.0([email protected])
+ pg-protocol: 1.16.0
+ pg-types: 2.2.0
+ pgpass: 1.0.5
+ optionalDependencies:
+ pg-cloudflare: 1.4.0
+
+ dependencies:
+ split2: 4.2.0
+
@@ -2892,44 +3852,27 @@ snapshots:
@@ -2892,44 +3852,27 @@ snapshots:
picocolors: 1.1.1
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.1
- dependencies:
- detect-libc: 2.1.2
- expand-template: 2.0.3
- github-from-package: 0.0.0
- minimist: 1.2.8
- mkdirp-classic: 0.5.3
- napi-build-utils: 2.0.0
- node-abi: 3.94.0
- pump: 3.0.4
- rc: 1.2.8
- simple-get: 4.0.1
- tar-fs: 2.1.5
- tunnel-agent: 0.6.0
+ [email protected]: {}
- [email protected]: {}
+ [email protected]: {}
- [email protected]: {}
+ [email protected]: {}
dependencies:
dependencies:
- end-of-stream: 1.4.5
- once: 1.4.0
+ xtend: 4.0.2
+
+ [email protected]: {}
+
+ [email protected]: {}
- dependencies:
- deep-extend: 0.6.0
- ini: 1.3.8
- minimist: 1.2.8
- strip-json-comments: 2.0.1
+ [email protected]: {}
dependencies:
dependencies:
- inherits: 2.0.4
- string_decoder: 1.3.0
- util-deprecate: 1.0.2
+ redis-errors: 1.2.0
@@ -2983,25 +3926,15 @@ snapshots:
@@ -2983,25 +3926,15 @@ snapshots:
- [email protected]: {}
-
- dependencies:
- decompress-response: 6.0.0
- once: 1.4.0
- simple-concat: 1.0.1
-
+ [email protected]: {}
+
- [email protected]: {}
+ [email protected]: {}
- dependencies:
- safe-buffer: 5.2.1
-
- [email protected]: {}
+ [email protected]: {}
@@ -3015,21 +3948,6 @@ snapshots:
@@ -3015,21 +3948,6 @@ snapshots:
- dependencies:
- chownr: 1.1.4
- mkdirp-classic: 0.5.3
- pump: 3.0.4
- tar-stream: 2.2.0
-
- dependencies:
- bl: 4.1.0
- end-of-stream: 1.4.5
- fs-constants: 1.0.0
- inherits: 2.0.4
- readable-stream: 3.6.2
-
@@ -3069,10 +3987,6 @@ snapshots:
@@ -3069,10 +3987,6 @@ snapshots:
optionalDependencies:
optionalDependencies:
fsevents: 2.3.3
fsevents: 2.3.3
- dependencies:
- safe-buffer: 5.2.1
-
dependencies:
dependencies:
prelude-ls: 1.2.1
prelude-ls: 1.2.1
@@ -3098,8 +4012,6 @@ snapshots:
@@ -3098,8 +4012,6 @@ snapshots:
dependencies:
dependencies:
punycode: 2.3.1
punycode: 2.3.1
- [email protected]: {}
-
dependencies:
dependencies:
cac: 6.7.14
cac: 6.7.14
@@ -3224,14 +4136,14 @@ snapshots:
@@ -3224,14 +4136,14 @@ snapshots:
- [email protected]: {}
-
+ [email protected]: {}
+
@@ -3,5 +3,4 @@ packages:
@@ -3,5 +3,4 @@ packages:
- apps/*
- apps/*
onlyBuiltDependencies:
onlyBuiltDependencies:
- - better-sqlite3
- esbuild
- esbuild
@@ -9,5 +9,8 @@ export default defineConfig({
@@ -9,5 +9,8 @@ export default defineConfig({
include: ['packages/**/*.test.ts', 'apps/**/*.test.ts', 'apps/**/*.audit.ts'],
include: ['packages/**/*.test.ts', 'apps/**/*.test.ts', 'apps/**/*.audit.ts'],
exclude: ['**/node_modules/**', '**/dist/**'],
exclude: ['**/node_modules/**', '**/dist/**'],
environment: 'node',
environment: 'node',
+ // Every persistence test runs against a real in-memory PGlite (WASM), which takes seconds to start.
+ testTimeout: 30_000,
+ hookTimeout: 30_000,
},
},
})
})