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).

OP OpenVibers committed 29 Sept 2026 · 540f672d0f · 69 files, +5959 −3194

- packages/persistence: openPgStore on an openvibe-sdk/db handle, every repository async, batch upserts as multi-row INSERT ... ON CONFLICT in chunks of 500; migrations/0001_initial.sql: places, world_meta, world_entities and world_constraints keyed by place, characters (account_key is the subject, or guest: + sha256 of a guest token: a raw guest token is never stored), mods, grants, placements, audit, media mirrors, account data events, identity adoptions, and the SDK's event_outbox and token_revocations. transaction() hands the tx to its callback; the outbox enqueue takes it and refuses to run outside one.
- The write-behind flusher (apps/server/src/game/flusher.ts): the tick builds a copy-on-write snapshot and never awaits I/O; one transaction at a time writes meta, NPCs (now dirty-tracked), entities, constraints, characters and their outbox events; a checkpoint while one is in flight is coalesced (flags stay set); a failed one restores its dirtiness. The shutdown checkpoint is never coalesced (queued behind one in flight), drain has a real deadline, a failed disconnect save retries in place with backoff, and a returning player's sign-in waits for queued saves before reading the character.
- No database access on the tick: markets and env preload at boot, mod placements come from an in-memory mirror written through a serialized queue (rolled back if the write fails), an offline owner's friends load asynchronously (denied until loaded), hello resolves asynchronously with an in-flight guard.
- Platform adapters on the SDK handle (createPgOutbox, createPgRevocationStore, account data), readiness via db.ready(), per-actor limits on Valkey when VALKEY_URL is set; development without DATABASE_URL runs on PGlite; migrations run as the owner on DATABASE_DIRECT_URL. Unhandled rejections closed in /api/v1/platform, readiness, the media mirror pass and account events; the Network revision advances only after its writes; a failed budget disable keeps enforcing.
- openvibe-sdk v0.22.0, openvibe-shared v1.30.1; better-sqlite3 removed.
Built by DeepSeek from Claude's spec; reviewed independently twice (FIX, FIX) and by Claude (the shutdown coalescing loss, drain deadline, disconnect retries and sign-in ordering fixed by Claude); every finding fixed with tests. pnpm typecheck, lint, test 872/872, slice test OK on PGlite, server bundle builds. Not deployed yet: production needs its database (roles/data/add-service.sh games) and Host's Games strategy moved off better-sqlite3 first.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..c94d72c
---- /dev/null
++++ b/.env.example
@@@ -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
ddiff --git a/.gitignore b/.gitignore
iindex 2ef0f72..d18be7d 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -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_Store
scratch/
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex fd63b17..f6b4f15 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -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)$''']
ddiff --git a/.npmrc b/.npmrc
nnew file mode 100644
iindex 0000000..a14fdaf
---- /dev/null
++++ b/.npmrc
@@@ -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
ddiff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
iindex 09d444c..cf9792f 100644
---- a/ARCHITECTURE.md
++++ b/ARCHITECTURE.md
@@@ -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 is
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
5. 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
ddiff --git a/README.md b/README.md
iindex f86ce2c..c0c5b29 100644
---- a/README.md
++++ b/README.md
@@@ -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 registry
gameplay/ Pure domain logic: inventory, crafting, movement sim, zones, entities
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
```
@@@ -104,27 +106,34 @@ freeze. Props are protected: only you and players you trust can move them.
```bash
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
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
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`
--`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 as
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
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
@@@ -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-subjects
made for a player (`/api/auth/me`, code and FedCM exchanges) keep using the
player'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
ddiff --git a/STATUS.json b/STATUS.json
iindex 3c1d83a..8836928 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/apps/client/e2e/editorCheck.ts b/apps/client/e2e/editorCheck.ts
iindex d60d5f5..727e3e8 100644
---- a/apps/client/e2e/editorCheck.ts
++++ b/apps/client/e2e/editorCheck.ts
@@@ -83,7 +83,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
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',
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',
ddiff --git a/apps/client/e2e/editorCollabCheck.ts b/apps/client/e2e/editorCollabCheck.ts
iindex b47f1ad..a5a6459 100644
---- a/apps/client/e2e/editorCollabCheck.ts
++++ b/apps/client/e2e/editorCollabCheck.ts
@@@ -64,7 +64,10 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
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',
MAP_PATH: mapPath,
EDITOR_KEY: KEY,
ddiff --git a/apps/client/scripts/visualTest.ts b/apps/client/scripts/visualTest.ts
iindex 7eab085..7e1c497 100644
---- a/apps/client/scripts/visualTest.ts
++++ b/apps/client/scripts/visualTest.ts
@@@ -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 } } })
++ .__openvibe
w.input.pitch = 0
w.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> } } })
++ .__openvibe
return 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> } } })
++ .__openvibe
return [...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 dbg
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.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')
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 003526e..30ee45f 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -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"
}
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex 1c410db..e9f0f71 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -32,7 +32,6 @@ import { encodeHeights } from '@openvibe/content'
const PORT = 18123
const 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 = 32
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 = {
-- 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,
},
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
nnew file mode 100644
iindex 0000000..618fcd2
---- /dev/null
++++ b/apps/server/src/config.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 06d2de2..eab7307 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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: number
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. */
staticDir: string | null
mapPath: 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). */
ddiff --git a/apps/server/src/db.ts b/apps/server/src/db.ts
nnew file mode 100644
iindex 0000000..e9f04a2
---- /dev/null
++++ b/apps/server/src/db.ts
@@@ -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 })
++}
ddiff --git a/apps/server/src/game/flusher.test.ts b/apps/server/src/game/flusher.test.ts
nnew file mode 100644
iindex 0000000..b6482f8
---- /dev/null
++++ b/apps/server/src/game/flusher.test.ts
@@@ -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([])
++ })
++})
ddiff --git a/apps/server/src/game/flusher.ts b/apps/server/src/game/flusher.ts
nnew file mode 100644
iindex 0000000..373c892
---- /dev/null
++++ b/apps/server/src/game/flusher.ts
@@@ -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),
++ })
++ }
++}
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 8c4cd79..c0196b2 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -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_MOVEMENT
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
@@@ -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: Flusher
private tick = 0
private 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 = 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 }[] = []
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),
++ )
}
/**
ddiff --git a/apps/server/src/game/gameWorld.ts b/apps/server/src/game/gameWorld.ts
iindex 8da304b..555a5a2 100644
---- a/apps/server/src/game/gameWorld.ts
++++ b/apps/server/src/game/gameWorld.ts
@@@ -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.id
if (!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 = 0
for (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) continue
if (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 {
ddiff --git a/apps/server/src/game/npcManager.ts b/apps/server/src/game/npcManager.ts
iindex 0bcb971..6dedfc4 100644
---- a/apps/server/src/game/npcManager.ts
++++ b/apps/server/src/game/npcManager.ts
@@@ -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) continue
const 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) : undefined
if (!npc || !arch || npc.respawnAt > 0) return null
npc.health -= amount
++ this.dirtyIds.add(npc.id)
if (npc.health > 0) return 'hurt'
npc.respawnAt = nowMs + arch.respawnSeconds * 1000
npc.health = 0
@@@ -151,6 +156,7 @@ export class NpcManager {
npc.x = npc.homeX
npc.z = npc.homeZ
npc.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) * step
npc.z += (dz / dist) * step
npc.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) continue
dtos.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)
}
}
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex 96fa9e8..513d723 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -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: Db
beforeAll(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: PgPersistenceStore
world: GameWorld
game: GameServer
registry: ModRegistry
-- outbox: Outbox
++ outbox: PgOutbox
dispose(): 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 = 0
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.
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 }).playerId
c.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)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 8e29f3e..2f943a2 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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 = null
let sink: EventSink = NO_EVENTS
if (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 })
: null
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 =
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 = 0
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' }),
})
@@@ -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 true
if (serveShared(req, res)) return true
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 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 = null
const 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 httpDone
await 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) => {
ddiff --git a/apps/server/src/mods/moderationEvents.test.ts b/apps/server/src/mods/moderationEvents.test.ts
iindex 7d9aa68..67612f7 100644
---- a/apps/server/src/mods/moderationEvents.test.ts
++++ b/apps/server/src/mods/moderationEvents.test.ts
@@@ -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()
})
})
ddiff --git a/apps/server/src/mods/networkGrants.test.ts b/apps/server/src/mods/networkGrants.test.ts
iindex bddb5be..685fe95 100644
---- a/apps/server/src/mods/networkGrants.test.ts
++++ b/apps/server/src/mods/networkGrants.test.ts
@@@ -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[] = [
ddiff --git a/apps/server/src/mods/registry.ts b/apps/server/src/mods/registry.ts
iindex 15fbb8e..fc42e2e 100644
---- a/apps/server/src/mods/registry.ts
++++ b/apps/server/src/mods/registry.ts
@@@ -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 = 0
constructor(
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: ModManifest
pack: ContentPack
approve: string[]
trustTier: ModTrustTier
enable: 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 view
const 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 view
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)
}
-- 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 = view
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) {
if (current.granted.has(cap)) continue
try {
@@@ -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 view
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).
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.publisher
const owner = publisher && publisher.type === 'user' ? publisher.id : null
if (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),
)
ddiff --git a/apps/server/src/mods/routes.test.ts b/apps/server/src/mods/routes.test.ts
iindex c62e8fa..7e32d57 100644
---- a/apps/server/src/mods/routes.test.ts
++++ b/apps/server/src/mods/routes.test.ts
@@@ -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)
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
iindex a0b5c94..a9fa031 100644
---- a/apps/server/src/mods/routes.ts
++++ b/apps/server/src/mods/routes.ts
@@@ -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 = 0
if (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')
ddiff --git a/apps/server/src/mods/runtime.test.ts b/apps/server/src/mods/runtime.test.ts
iindex 8e57365..5996d6d 100644
---- a/apps/server/src/mods/runtime.test.ts
++++ b/apps/server/src/mods/runtime.test.ts
@@@ -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_000
const 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 tick
expect(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()
})
})
ddiff --git a/apps/server/src/mods/runtime.ts b/apps/server/src/mods/runtime.ts
iindex 54e3f9e..c287918 100644
---- a/apps/server/src/mods/runtime.ts
++++ b/apps/server/src/mods/runtime.ts
@@@ -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 false
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 })
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)) return
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. */
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
let removed = 0
for (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 })
ddiff --git a/apps/server/src/mods/security.test.ts b/apps/server/src/mods/security.test.ts
iindex c106bfd..7183a3a 100644
---- a/apps/server/src/mods/security.test.ts
++++ b/apps/server/src/mods/security.test.ts
@@@ -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_000
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 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'],
ddiff --git a/apps/server/src/net/actorLimits.ts b/apps/server/src/net/actorLimits.ts
iindex 7db5a97..fd48b65 100644
---- a/apps/server/src/net/actorLimits.ts
++++ b/apps/server/src/net/actorLimits.ts
@@@ -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.ProcessEnv
now?: () => number
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 store = opts.valkey ? createValkeyLimitStore(opts.valkey) : null
const 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.
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
iindex 312895f..44280a8 100644
---- a/apps/server/src/net/httpServer.test.ts
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -90,7 +90,7 @@ beforeAll(async () => {
metrics.tick = 42
metrics.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,
ddiff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts
iindex 6ba3fa3..0bed38e 100644
---- a/apps/server/src/observability/metrics.ts
++++ b/apps/server/src/observability/metrics.ts
@@@ -18,7 +18,12 @@ export class ServerMetrics {
bytesOut = 0
messagesOut = 0
snapshotBytes = 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 = 0
constraintIslands = 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,
ddiff --git a/apps/server/src/observability/readiness.test.ts b/apps/server/src/observability/readiness.test.ts
iindex 1ebb870..702af69 100644
---- a/apps/server/src/observability/readiness.test.ts
++++ b/apps/server/src/observability/readiness.test.ts
@@@ -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 = 301
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.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)
ddiff --git a/apps/server/src/observability/readiness.ts b/apps/server/src/observability/readiness.ts
iindex d5b01ca..ac09821 100644
---- a/apps/server/src/observability/readiness.ts
++++ b/apps/server/src/observability/readiness.ts
@@@ -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: Outcome
try {
-- 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.now
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) {
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
}
ddiff --git a/apps/server/src/platform/accountData.test.ts b/apps/server/src/platform/accountData.test.ts
iindex 851dc25..82f5558 100644
---- a/apps/server/src/platform/accountData.test.ts
++++ b/apps/server/src/platform/accountData.test.ts
@@@ -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: string
body: {
@@@ -103,8 +103,10 @@ describe('account export and deletion (ADR-033)', () => {
failNext = true
await 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()
})
})
ddiff --git a/apps/server/src/platform/accountData.ts b/apps/server/src/platform/accountData.ts
iindex 59bd109..ffa7503 100644
---- a/apps/server/src/platform/accountData.ts
++++ b/apps/server/src/platform/accountData.ts
@@@ -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; statu
interface Record_ {
id: string
-- outcome: string | null
++ outcome: unknown
sent_at: string | null
applied_at: string
}
export function createAccountData(opts: {
-- db: Database.Database
++ db: Db
identity: IdentityRepository
send: 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'
: null
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
}
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
}
ddiff --git a/apps/server/src/platform/gameEvents.test.ts b/apps/server/src/platform/gameEvents.test.ts
iindex 629c149..9d8d52c 100644
---- a/apps/server/src/platform/gameEvents.test.ts
++++ b/apps/server/src/platform/gameEvents.test.ts
@@@ -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 Contra
const 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 Tx
function 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 = 0
const 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()
})
})
ddiff --git a/apps/server/src/platform/gameEvents.ts b/apps/server/src/platform/gameEvents.ts
iindex b83570e..7adc292 100644
---- a/apps/server/src/platform/gameEvents.ts
++++ b/apps/server/src/platform/gameEvents.ts
@@@ -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 + players
const 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,
}),
)
ddiff --git a/apps/server/src/platform/mediaMirror.test.ts b/apps/server/src/platform/mediaMirror.test.ts
iindex 8fdb1c4..5780600 100644
---- a/apps/server/src/platform/mediaMirror.test.ts
++++ b/apps/server/src/platform/mediaMirror.test.ts
@@@ -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_000
const 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([])
})
})
ddiff --git a/apps/server/src/platform/mediaMirror.ts b/apps/server/src/platform/mediaMirror.ts
iindex a4b562c..bfcb7c8 100644
---- a/apps/server/src/platform/mediaMirror.ts
++++ b/apps/server/src/platform/mediaMirror.ts
@@@ -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) continue
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 },
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 ?? 0
const attempts = row.attempts + 1
const terminal = TERMINAL_STATUS.has(status) || attempts >= MAX_ATTEMPTS
const wait = BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)] ?? 60_000
const 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,
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
iindex ac962b5..3528948 100644
---- a/apps/server/src/platform/revocationEvents.test.ts
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -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) => unknown
const { 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: Server
let 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> = {}) =>
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
iindex 49f4908..35b1095 100644
---- a/apps/server/src/platform/revocationEvents.ts
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -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): boolean
cutoffFor(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: Db
secrets: 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: string
approved: 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> = null
for (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
}
ddiff --git a/apps/server/src/shutdown.test.ts b/apps/server/src/shutdown.test.ts
iindex 6040e0b..af9778a 100644
---- a/apps/server/src/shutdown.test.ts
++++ b/apps/server/src/shutdown.test.ts
@@@ -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.
ddiff --git a/deploy/openvibe-games.service b/deploy/openvibe-games.service
iindex 608a25d..1b3ff95 100644
---- a/deploy/openvibe-games.service
++++ b/deploy/openvibe-games.service
@@@ -17,7 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
Environment=NODE_ENV=production
Environment=PORT=8000
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=STATIC_DIR=/opt/openvibe.games/apps/client/dist
Environment=SELF_URL=https://openvibe.games
@@@ -25,7 +28,7 @@ Environment=PLAY_URL=https://play.openvibe.games
EnvironmentFile=/etc/openvibe/games.env
Restart=always
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
KillSignal=SIGTERM
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex b21f120..d1ca0ea 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -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
ddiff --git a/packages/content/src/defs/items.ts b/packages/content/src/defs/items.ts
iindex a11f34e..ed5ee67 100644
---- a/packages/content/src/defs/items.ts
++++ b/packages/content/src/defs/items.ts
@@@ -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.',
ddiff --git a/packages/persistence/migrations/0001_initial.sql b/packages/persistence/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..701a882
---- /dev/null
++++ b/packages/persistence/migrations/0001_initial.sql
@@@ -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
++);
ddiff --git a/packages/persistence/package.json b/packages/persistence/package.json
iindex 025ea5a..d6b2eec 100644
---- a/packages/persistence/package.json
++++ b/packages/persistence/package.json
@@@ -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"
}
}
ddiff --git a/packages/persistence/src/accountKey.test.ts b/packages/persistence/src/accountKey.test.ts
nnew file mode 100644
iindex 0000000..cb3aeba
---- /dev/null
++++ b/packages/persistence/src/accountKey.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/persistence/src/accountKey.ts b/packages/persistence/src/accountKey.ts
nnew file mode 100644
iindex 0000000..a5c4231
---- /dev/null
++++ b/packages/persistence/src/accountKey.ts
@@@ -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')}`
++}
ddiff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
iindex 5618ec0..4e0f415 100644
---- a/packages/persistence/src/index.ts
++++ b/packages/persistence/src/index.ts
@@@ -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'
ddiff --git a/packages/persistence/src/migrations.ts b/packages/persistence/src/migrations.ts
nnew file mode 100644
iindex 0000000..3c80204
---- /dev/null
++++ b/packages/persistence/src/migrations.ts
@@@ -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))
ddiff --git a/packages/persistence/src/pg/index.ts b/packages/persistence/src/pg/index.ts
nnew file mode 100644
iindex 0000000..0fed1c3
---- /dev/null
++++ b/packages/persistence/src/pg/index.ts
@@@ -0,0 +1,2 @@
++export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pgStore.js'
++export { BATCH_CHUNK } from './sqlUtils.js'
ddiff --git a/packages/persistence/src/pg/pgStore.test.ts b/packages/persistence/src/pg/pgStore.test.ts
nnew file mode 100644
iindex 0000000..85eeab4
---- /dev/null
++++ b/packages/persistence/src/pg/pgStore.test.ts
@@@ -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')
++ })
++})
ddiff --git a/packages/persistence/src/pg/pgStore.ts b/packages/persistence/src/pg/pgStore.ts
nnew file mode 100644
iindex 0000000..4861f94
---- /dev/null
++++ b/packages/persistence/src/pg/pgStore.ts
@@@ -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()
++ },
++ }
++}
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
nnew file mode 100644
iindex 0000000..0e215de
---- /dev/null
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -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()
++ })
++})
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
nnew file mode 100644
iindex 0000000..cf6b47d
---- /dev/null
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -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
++ },
++ }
++}
ddiff --git a/packages/persistence/src/pg/sqlUtils.ts b/packages/persistence/src/pg/sqlUtils.ts
nnew file mode 100644
iindex 0000000..3cc9d70
---- /dev/null
++++ b/packages/persistence/src/pg/sqlUtils.ts
@@@ -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)
++}
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 43642e3..52d17d4 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -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: number
structures_unowned: number
identity_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: WorldEntityRepository
readonly players: PlayerRepository
readonly constraints: ConstraintRepository
@@@ -149,10 +170,12 @@ export interface PersistenceStore {
readonly mods: ModRepository
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>
}
ddiff --git a/packages/persistence/src/sqlite/index.ts b/packages/persistence/src/sqlite/index.ts
ddeleted file mode 100644
iindex 33935c1..0000000
---- a/packages/persistence/src/sqlite/index.ts
++++ /dev/null
@@@ -1 +0,0 @@
--export { openSqliteStore, type SqlitePersistenceStore } from './sqliteStore.js'
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.test.ts b/packages/persistence/src/sqlite/platformRepositories.test.ts
ddeleted file mode 100644
iindex 69536bd..0000000
---- a/packages/persistence/src/sqlite/platformRepositories.test.ts
++++ /dev/null
@@@ -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()
-- })
--})
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.ts b/packages/persistence/src/sqlite/platformRepositories.ts
ddeleted file mode 100644
iindex 7eb7b35..0000000
---- a/packages/persistence/src/sqlite/platformRepositories.ts
++++ /dev/null
@@@ -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
-- },
-- }
--}
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.test.ts b/packages/persistence/src/sqlite/sqliteStore.test.ts
ddeleted file mode 100644
iindex 5312460..0000000
---- a/packages/persistence/src/sqlite/sqliteStore.test.ts
++++ /dev/null
@@@ -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()
-- })
--})
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.ts b/packages/persistence/src/sqlite/sqliteStore.ts
ddeleted file mode 100644
iindex ae66497..0000000
---- a/packages/persistence/src/sqlite/sqliteStore.ts
++++ /dev/null
@@@ -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()
-- },
-- }
--}
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
nnew file mode 100644
iindex 0000000..bc1ac70
---- /dev/null
++++ b/packages/persistence/src/testing.ts
@@@ -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 () => {} }
++}
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 4293c3a..99f5765 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -1,11 +1,10 @@
lockfileVersion: '9.0'
settings:
-- autoInstallPeers: true
++ autoInstallPeers: false
excludeLinksFromLockfile: false
importers:
--
.:
devDependencies:
'@eslint/js':
@@@ -87,6 +86,9 @@ importers:
'@babylonjs/havok':
specifier: ^1.3.10
version: 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.1
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:
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
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:
-- 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:
specifier: ^4.19.3
version: 4.23.11
@@@ -127,9 +132,6 @@ importers:
specifier: ^8.18.1
version: 8.21.3
devDependencies:
-- '@types/better-sqlite3':
-- specifier: ^7.6.12
-- version: 7.6.13
'@types/ws':
specifier: ^8.18.0
version: 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.0
babylonjs-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.1
peerDependenciesMeta:
@@@ -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.0
peerDependenciesMeta:
@@@ -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==}
--
-- resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
++ resolution:
++ {
++ integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==,
++ }
-- resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
++ resolution:
++ {
++ integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==,
++ }
-- resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
++ resolution:
++ {
++ integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==,
++ }
-- resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
++ resolution:
++ {
++ integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==,
++ }
-- resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
++ resolution:
++ {
++ integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
++ }
-- resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
++ resolution:
++ {
++ integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==,
++ }
-- 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.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=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.0
typescript: '>=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.0
typescript: '>=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.0
typescript: '>=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.9
vite: ^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.0
peerDependenciesMeta:
@@@ -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: true
peerDependencies:
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 || ^4
peerDependenciesMeta:
@@@ -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.3
peerDependenciesMeta:
@@@ -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.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
-- 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: true
peerDependencies:
'@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: true
peerDependencies:
'@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: true
peerDependencies:
'@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.1
utf-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
--
dependencies:
'@types/deep-eql': 4.0.2
@@@ -2366,27 +3331,10 @@ snapshots:
--
-- 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:
dependencies:
@@@ -2456,26 +3399,16 @@ snapshots:
-- dependencies:
-- mimic-response: 3.1.0
--
--
dependencies:
safe-buffer: 5.2.1
-- dependencies:
-- once: 1.4.0
--
@@@ -2612,8 +3545,6 @@ snapshots:
--
@@@ -2632,8 +3563,6 @@ snapshots:
dependencies:
flat-cache: 4.0.1
--
dependencies:
locate-path: 6.0.0
@@@ -2646,16 +3575,12 @@ snapshots:
--
optional: true
optional: true
--
dependencies:
is-glob: 4.0.3
@@@ -2670,8 +3595,6 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
--
@@@ -2683,9 +3606,19 @@ snapshots:
--
++ 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
++
++
@@@ -2800,8 +3737,6 @@ snapshots:
--
dependencies:
brace-expansion: 5.0.9
@@@ -2810,34 +3745,24 @@ snapshots:
dependencies:
brace-expansion: 1.1.18
--
--
--
-- 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.0
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:
jsonwebtoken: 9.0.3
@@@ -2874,6 +3799,41 @@ snapshots:
++ optional: true
++
++
++
++ dependencies:
++ pg: 8.23.0
++
++
++ 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.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
dependencies:
-- end-of-stream: 1.4.5
-- once: 1.4.0
++ xtend: 4.0.2
++
++
-- dependencies:
-- deep-extend: 0.6.0
-- ini: 1.3.8
-- minimist: 1.2.8
-- strip-json-comments: 2.0.1
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:
--
-- dependencies:
-- decompress-response: 6.0.0
-- once: 1.4.0
-- simple-concat: 1.0.1
--
++
-- dependencies:
-- safe-buffer: 5.2.1
--
@@@ -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
--
dependencies:
cac: 6.7.14
@@@ -3224,14 +4136,14 @@ snapshots:
--
++
ddiff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
iindex efb95e0..a298b2c 100644
---- a/pnpm-workspace.yaml
++++ b/pnpm-workspace.yaml
@@@ -3,5 +3,4 @@ packages:
- apps/*
onlyBuiltDependencies:
-- - better-sqlite3
- esbuild
ddiff --git a/vitest.config.ts b/vitest.config.ts
iindex afacbac..61f88c4 100644
---- a/vitest.config.ts
++++ b/vitest.config.ts
@@@ -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,
},
})