openvibers/openvibe.games Commits
Persistence schema 12: identity legacy map, mod registry (installs, grants, placements, audit), Media mirror queue, players.subject_id, and store.transaction() so a flush and its outbox events commit together
Changes
@@@ -29,8 +29,14 @@ export interface WorldEntityDto {export interface PlayerDto {id: string-- /** Identity token from the client (interim auth; see ADR-0004). */++ /**++ * Account key. Signed-in accounts use their canonical openvibe.network++ * subject (`usr_…`/`gst_…`, ADR-0006); guests use their browser token++ * (ADR-0004), which can never take that shape.++ */token: string++ /** Canonical openvibe.network subject when the account is signed in; absent for local guests. */++ subjectId?: stringname: stringpos: [number, number, number]yaw: number@@@ -68,3 +74,79 @@ export interface ConstraintDto {params: Record<string, unknown> | nullupdatedAt: number}++++/** How far the platform trusts a mod's publisher. Metadata only (ADR-013): never consulted by a grant check. */++export type ModTrustTier = 'unreviewed' | 'reviewed' | 'first-party'++++/** An installed mod lifecycle state. `revoked` is terminal for the install. */++export type ModStatus = 'enabled' | 'disabled' | 'revoked'++++/** One installed mod: its validated manifest, its runtime payload and its lifecycle. */++export interface ModInstallDto {++ /** `mod_<ULID>` from the manifest. */++ id: string++ name: string++ version: string++ /** Runtime target, e.g. `games.browser`. */++ target: string++ /** Runtime adapter, e.g. `games-content@1`. */++ runtime: string++ /** The full manifest as installed (mods/mod-manifest.v1). */++ manifest: Record<string, unknown>++ /** The runtime-specific payload (a content data pack for `games-content@1`). */++ pack: Record<string, unknown>++ trustTier: ModTrustTier++ status: ModStatus++ /** Who installed it: a subject id or `svc:<client>`. */++ installedBy: string++ installedAt: number++ updatedAt: number++}++++/** An approved capability of an installed mod; `revokedAt` set = no longer granted. */++export interface ModGrantDto {++ modId: string++ capability: string++ grantedBy: string++ grantedAt: number++ revokedAt: number | null++ revokedBy: string | null++}++++/** Append-only audit record: install, grant, use, deny, revoke, … */++export interface ModAuditDto {++ id?: number++ modId: string++ action: string++ capability: string | null++ actor: string++ detail: Record<string, unknown> | null++ at: number++}++++/** A world entity a mod placed, keyed by the placement key in its pack. */++export interface ModPlacementDto {++ modId: string++ key: string++ /** The world entity id; null once the entity is gone (destroyed) — it is not placed again. */++ entityId: string | null++ at: number++}++++/** A durable copy of a local content-addressed asset in OpenVibe.Media. */++export interface MediaMirrorDto {++ /** `sha256-<hex>` — the local asset identity. */++ assetHash: string++ /** Local file name under the map-assets directory. */++ fileName: string++ mime: string++ bytes: number++ status: 'pending' | 'mirrored' | 'failed'++ /** Media object id (`med_…`) once mirrored. */++ mediaId: string | null++ publicUrl: string | null++ attempts: number++ lastError: string | null++ nextAttemptAt: number++ updatedAt: number++}@@@ -1,4 +1,14 @@--import type { ConstraintDto, PlayerDto, WorldEntityDto } from './dto.js'++import type {++ ConstraintDto,++ MediaMirrorDto,++ ModAuditDto,++ ModGrantDto,++ ModInstallDto,++ ModPlacementDto,++ ModStatus,++ PlayerDto,++ WorldEntityDto,++} from './dto.js'/*** Repository boundary: gameplay/server code never touches SQL or the@@@ -39,6 +49,72 @@ export interface ConstraintRepository {deleteMany(ids: readonly string[]): void}++/**++ * Canonical identity (ADR-0006): signed-in accounts are keyed by their++ * openvibe.network subject. Accounts created before that were keyed by a++ * legacy key (`ovn:<network user id>`); adoption moves them over once and++ * remembers the mapping.++ */++export interface IdentityRepository {++ /**++ * Moves every character under `legacyKey` to `subjectId` (slots the subject++ * does not already use) and records the mapping. Idempotent: a second call++ * moves nothing. Characters whose slot is taken stay under the legacy key++ * and are reported as `conflicts`.++ */++ adoptLegacyAccount(++ legacyKey: string,++ subjectId: string,++ source: string,++ now: number,++ ): { moved: number; conflicts: number }++ /** The subject a legacy key was adopted into, if any. */++ subjectForLegacy(legacyKey: string): string | null++ /** Distinct account keys under a legacy prefix that still own characters. */++ legacyAccountKeys(prefix: string): string[]++}++++/** 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[]++ /** Grants (or re-grants) a capability. */++ upsertGrant(grant: ModGrantDto): 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++}++++/** Queue of local assets to copy into OpenVibe.Media. */++export interface MediaMirrorRepository {++ /** Queues an asset; false when it is already known (queued or mirrored). */++ enqueue(++ asset: { assetHash: string; fileName: string; mime: string; bytes: number },++ now: number,++ ): boolean++ get(assetHash: string): MediaMirrorDto | null++ /** Pending rows whose next attempt is due, oldest first. */++ due(now: number, limit: number): 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++ markFailed(++ assetHash: string,++ error: string,++ nextAttemptAt: number,++ terminal: boolean,++ now: number,++ ): void++ counts(): Record<MediaMirrorDto['status'], number>++}++export interface MetaRepository {get(key: string): string | nullset(key: string, value: string): void@@@ -50,5 +126,14 @@ export interface PersistenceStore {guests: GuestRepositoryreadonly constraints: ConstraintRepositoryreadonly meta: MetaRepository++ readonly identity: IdentityRepository++ 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.++ */++ transaction<T>(fn: () => T): Tclose(): void}@@@ -1 +1 @@--export { openSqliteStore } from './sqliteStore.js'++export { openSqliteStore, type SqlitePersistenceStore } from './sqliteStore.js'nnew file mode 100644@@@ -0,0 +1,208 @@++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('identity repository (legacy -> canonical subject)', () => {++ it('moves every legacy character to the subject once and records the map', () => {++ const store = openSqliteStore(':memory:')++ store.players.upsertMany([player('p1', 'ovn:57', 0), player('p2', 'ovn:57', 1)])++++ expect(store.identity.legacyAccountKeys('ovn:')).toEqual(['ovn:57'])++ expect(store.identity.adoptLegacyAccount('ovn:57', SUBJECT, 'network', 100)).toEqual({++ moved: 2,++ conflicts: 0,++ })++ const chars = store.players.listByToken(SUBJECT)++ expect(chars.map((c) => [c.id, c.charSlot, c.subjectId])).toEqual([++ ['p1', 0, SUBJECT],++ ['p2', 1, SUBJECT],++ ])++ expect(store.players.listByToken('ovn:57')).toEqual([])++ expect(store.identity.subjectForLegacy('ovn:57')).toBe(SUBJECT)++ expect(store.identity.legacyAccountKeys('ovn:')).toEqual([])++++ // Idempotent: nothing left to move, the map is unchanged.++ expect(store.identity.adoptLegacyAccount('ovn:57', SUBJECT, 'network', 200)).toEqual({++ moved: 0,++ conflicts: 0,++ })++ expect(store.players.listByToken(SUBJECT)).toHaveLength(2)++ store.close()++ })++++ it('never overwrites a slot the subject already uses', () => {++ const store = openSqliteStore(':memory:')++ store.players.upsertMany([++ player('new0', SUBJECT, 0, { subjectId: SUBJECT }),++ player('old0', 'ovn:9', 0),++ player('old2', 'ovn:9', 2),++ ])++ expect(store.identity.adoptLegacyAccount('ovn:9', SUBJECT, 'network', 1)).toEqual({++ moved: 1,++ conflicts: 1,++ })++ expect(store.players.findByTokenSlot(SUBJECT, 0)?.id).toBe('new0')++ expect(store.players.findByTokenSlot(SUBJECT, 2)?.id).toBe('old2')++ // The conflicting character stays reachable under its legacy key.++ expect(store.players.findByTokenSlot('ovn:9', 0)?.id).toBe('old0')++ store.close()++ })++++ it('refuses to re-home a legacy key to a different subject', () => {++ const store = openSqliteStore(':memory:')++ store.players.upsert(player('p1', 'ovn:1', 0))++ store.identity.adoptLegacyAccount('ovn:1', SUBJECT, 'network', 1)++ store.players.upsert(player('p9', 'ovn:1', 1))++ expect(() => store.identity.adoptLegacyAccount('ovn:1', OTHER, 'network', 2)).toThrow(++ /already mapped/,++ )++ // Nothing moved by the refused call.++ expect(store.players.findById('p9')?.token).toBe('ovn:1')++ store.close()++ })++++ 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()++ })++})nnew file mode 100644@@@ -0,0 +1,355 @@++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 (schema 12): the++ * legacy identity map, 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 legacyRows = db.prepare<[string], { id: string; char_slot: number }>(++ 'SELECT id, char_slot FROM players WHERE token = ? ORDER BY char_slot',++ )++ const moveRow = db.prepare('UPDATE players SET token = ?, subject_id = ? WHERE id = ?')++ const recordMap = db.prepare(`++ INSERT INTO identity_legacy_map (legacy_key, subject_id, source, moved, conflicts, adopted_at)++ VALUES (?, ?, ?, ?, ?, ?)++ ON CONFLICT(legacy_key) DO UPDATE SET++ moved = identity_legacy_map.moved + excluded.moved,++ conflicts = excluded.conflicts++ `)++ const mapped = db.prepare<[string], { subject_id: string }>(++ 'SELECT subject_id FROM identity_legacy_map WHERE legacy_key = ?',++ )++++ const adopt = db.transaction(++ (legacyKey: string, subjectId: string, source: string, now: number) => {++ const existing = mapped.get(legacyKey)++ if (existing && existing.subject_id !== subjectId) {++ // A legacy key belongs to exactly one subject; a different answer++ // later is a data problem to look at, never a silent re-home.++ throw new Error(++ `legacy key ${legacyKey} is already mapped to ${existing.subject_id}, not ${subjectId}`,++ )++ }++ const taken = new Set(takenSlots.all(subjectId).map((r) => r.char_slot))++ let moved = 0++ let conflicts = 0++ for (const row of legacyRows.all(legacyKey)) {++ if (taken.has(row.char_slot)) {++ conflicts++++ continue++ }++ moveRow.run(subjectId, subjectId, row.id)++ taken.add(row.char_slot)++ moved++++ }++ recordMap.run(legacyKey, subjectId, source, moved, conflicts, now)++ return { moved, conflicts }++ },++ )++++ return {++ adoptLegacyAccount: (legacyKey, subjectId, source, now) =>++ adopt(legacyKey, subjectId, source, now),++ subjectForLegacy(legacyKey) {++ return mapped.get(legacyKey)?.subject_id ?? null++ },++ legacyAccountKeys(prefix) {++ // substr, not LIKE: `_` and `%` in a prefix must not act as wildcards.++ const rows = db++ .prepare(++ 'SELECT DISTINCT token FROM players WHERE substr(token, 1, length(@p)) = @p ORDER BY token',++ )++ .all({ p: prefix }) as { token: string }[]++ return rows.map((r) => r.token)++ },++ }++}++++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++ },++ }++}@@@ -153,7 +153,7 @@ describe('sqlite store', () => {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')++ expect(store.meta.get('schema_version')).toBe('12')// v9 armor column round-trips.store.players.upsert({...store.players.findByToken('tok_11111111')!,@@@ -9,6 +9,20 @@ import type {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@@@ -19,7 +33,7 @@ import type {* a database is upgraded step by step inside a transaction per step.*/--const SCHEMA_VERSION = 11++const SCHEMA_VERSION = 12const BASE_SCHEMA = `CREATE TABLE IF NOT EXISTS world_entities (@@@ -49,6 +63,7 @@ CREATE TABLE IF NOT EXISTS players (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);@@@ -70,6 +85,77 @@ CREATE TABLE IF NOT EXISTS meta ();`++/**++ * Platform integration tables (schema 12): canonical identity, the mod++ * registry and the Media mirror queue. Shared by the fresh-database path and++ * the 11 -> 12 migration 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_legacy_map (++ legacy_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) => {@@@ -146,6 +232,12 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {ALTER TABLE players ADD COLUMN active_job TEXT;`)},++ 11: (db) => {++ // Platform integration: canonical subject per account (ADR-0006), the++ // legacy identity map, the mod registry and the Media mirror queue.++ db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')++ db.exec(PLATFORM_SCHEMA)++ },}interface WorldEntityRow {@@@ -182,6 +274,7 @@ interface PlayerRow {reputation: stringunlocks: stringactive_job: string | null++ subject_id: string | nullchar_slot: numberupdated_at: number}@@@ -195,7 +288,7 @@ interface ConstraintRow {updated_at: number}--export function openSqliteStore(path: string): PersistenceStore {++export function openSqliteStore(path: string): SqlitePersistenceStore {const db = new Database(path)db.pragma('journal_mode = WAL')db.pragma('synchronous = NORMAL')@@@ -206,6 +299,7 @@ export function openSqliteStore(path: string): PersistenceStore {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),@@@ -293,9 +387,10 @@ export function openSqliteStore(path: string): PersistenceStore {}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, 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, @updated_at)++ 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`)@@@ -320,6 +415,7 @@ export function openSqliteStore(path: string): PersistenceStore {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,})@@@ -341,6 +437,7 @@ export function openSqliteStore(path: string): PersistenceStore {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,})@@@ -448,11 +545,18 @@ export function openSqliteStore(path: string): PersistenceStore {}return {++ db,worldEntities,players,guests,constraints,meta,++ identity: createIdentityRepository(db),++ mods: createModRepository(db),++ mediaMirrors: createMediaMirrorRepository(db),++ transaction<T>(fn: () => T): T {++ return db.transaction(fn)()++ },close(): void {db.close()},
@@ -29,8 +29,14 @@ export interface WorldEntityDto {
@@ -29,8 +29,14 @@ export interface WorldEntityDto {
export interface PlayerDto {
export interface PlayerDto {
id: string
id: string
- /** Identity token from the client (interim auth; see ADR-0004). */
+ /**
+ * Account key. Signed-in accounts use their canonical openvibe.network
+ * subject (`usr_…`/`gst_…`, ADR-0006); guests use their browser token
+ * (ADR-0004), which can never take that shape.
+ */
token: string
token: string
+ /** Canonical openvibe.network subject when the account is signed in; absent for local guests. */
+ subjectId?: string
name: string
name: string
pos: [number, number, number]
pos: [number, number, number]
yaw: number
yaw: number
@@ -68,3 +74,79 @@ export interface ConstraintDto {
@@ -68,3 +74,79 @@ export interface ConstraintDto {
params: Record<string, unknown> | null
params: Record<string, unknown> | null
updatedAt: number
updatedAt: number
}
}
+
+/** How far the platform trusts a mod's publisher. Metadata only (ADR-013): never consulted by a grant check. */
+export type ModTrustTier = 'unreviewed' | 'reviewed' | 'first-party'
+
+/** An installed mod lifecycle state. `revoked` is terminal for the install. */
+export type ModStatus = 'enabled' | 'disabled' | 'revoked'
+
+/** One installed mod: its validated manifest, its runtime payload and its lifecycle. */
+export interface ModInstallDto {
+ /** `mod_<ULID>` from the manifest. */
+ id: string
+ name: string
+ version: string
+ /** Runtime target, e.g. `games.browser`. */
+ target: string
+ /** Runtime adapter, e.g. `games-content@1`. */
+ runtime: string
+ /** The full manifest as installed (mods/mod-manifest.v1). */
+ manifest: Record<string, unknown>
+ /** The runtime-specific payload (a content data pack for `games-content@1`). */
+ pack: Record<string, unknown>
+ trustTier: ModTrustTier
+ status: ModStatus
+ /** Who installed it: a subject id or `svc:<client>`. */
+ installedBy: string
+ installedAt: number
+ updatedAt: number
+}
+
+/** An approved capability of an installed mod; `revokedAt` set = no longer granted. */
+export interface ModGrantDto {
+ modId: string
+ capability: string
+ grantedBy: string
+ grantedAt: number
+ revokedAt: number | null
+ revokedBy: string | null
+}
+
+/** Append-only audit record: install, grant, use, deny, revoke, … */
+export interface ModAuditDto {
+ id?: number
+ modId: string
+ action: string
+ capability: string | null
+ actor: string
+ detail: Record<string, unknown> | null
+ at: number
+}
+
+/** A world entity a mod placed, keyed by the placement key in its pack. */
+export interface ModPlacementDto {
+ modId: string
+ key: string
+ /** The world entity id; null once the entity is gone (destroyed) — it is not placed again. */
+ entityId: string | null
+ at: number
+}
+
+/** A durable copy of a local content-addressed asset in OpenVibe.Media. */
+export interface MediaMirrorDto {
+ /** `sha256-<hex>` — the local asset identity. */
+ assetHash: string
+ /** Local file name under the map-assets directory. */
+ fileName: string
+ mime: string
+ bytes: number
+ status: 'pending' | 'mirrored' | 'failed'
+ /** Media object id (`med_…`) once mirrored. */
+ mediaId: string | null
+ publicUrl: string | null
+ attempts: number
+ lastError: string | null
+ nextAttemptAt: number
+ updatedAt: number
+}
@@ -1,4 +1,14 @@
@@ -1,4 +1,14 @@
-import type { ConstraintDto, PlayerDto, WorldEntityDto } from './dto.js'
+import type {
+ ConstraintDto,
+ MediaMirrorDto,
+ ModAuditDto,
+ ModGrantDto,
+ ModInstallDto,
+ ModPlacementDto,
+ ModStatus,
+ PlayerDto,
+ WorldEntityDto,
+} from './dto.js'
/**
/**
* Repository boundary: gameplay/server code never touches SQL or the
* Repository boundary: gameplay/server code never touches SQL or the
@@ -39,6 +49,72 @@ export interface ConstraintRepository {
@@ -39,6 +49,72 @@ export interface ConstraintRepository {
deleteMany(ids: readonly string[]): void
deleteMany(ids: readonly string[]): void
}
}
+/**
+ * Canonical identity (ADR-0006): signed-in accounts are keyed by their
+ * openvibe.network subject. Accounts created before that were keyed by a
+ * legacy key (`ovn:<network user id>`); adoption moves them over once and
+ * remembers the mapping.
+ */
+export interface IdentityRepository {
+ /**
+ * Moves every character under `legacyKey` to `subjectId` (slots the subject
+ * does not already use) and records the mapping. Idempotent: a second call
+ * moves nothing. Characters whose slot is taken stay under the legacy key
+ * and are reported as `conflicts`.
+ */
+ adoptLegacyAccount(
+ legacyKey: string,
+ subjectId: string,
+ source: string,
+ now: number,
+ ): { moved: number; conflicts: number }
+ /** The subject a legacy key was adopted into, if any. */
+ subjectForLegacy(legacyKey: string): string | null
+ /** Distinct account keys under a legacy prefix that still own characters. */
+ legacyAccountKeys(prefix: string): string[]
+}
+
+/** 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[]
+ /** Grants (or re-grants) a capability. */
+ upsertGrant(grant: ModGrantDto): 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
+}
+
+/** Queue of local assets to copy into OpenVibe.Media. */
+export interface MediaMirrorRepository {
+ /** Queues an asset; false when it is already known (queued or mirrored). */
+ enqueue(
+ asset: { assetHash: string; fileName: string; mime: string; bytes: number },
+ now: number,
+ ): boolean
+ get(assetHash: string): MediaMirrorDto | null
+ /** Pending rows whose next attempt is due, oldest first. */
+ due(now: number, limit: number): 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
+ markFailed(
+ assetHash: string,
+ error: string,
+ nextAttemptAt: number,
+ terminal: boolean,
+ now: number,
+ ): void
+ counts(): Record<MediaMirrorDto['status'], number>
+}
+
export interface MetaRepository {
export interface MetaRepository {
get(key: string): string | null
get(key: string): string | null
set(key: string, value: string): void
set(key: string, value: string): void
@@ -50,5 +126,14 @@ export interface PersistenceStore {
@@ -50,5 +126,14 @@ export interface PersistenceStore {
guests: GuestRepository
guests: GuestRepository
readonly constraints: ConstraintRepository
readonly constraints: ConstraintRepository
readonly meta: MetaRepository
readonly meta: MetaRepository
+ readonly identity: IdentityRepository
+ 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.
+ */
+ transaction<T>(fn: () => T): T
close(): void
close(): void
}
}
@@ -1 +1 @@
@@ -1 +1 @@
-export { openSqliteStore } from './sqliteStore.js'
+export { openSqliteStore, type SqlitePersistenceStore } from './sqliteStore.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+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('identity repository (legacy -> canonical subject)', () => {
+ it('moves every legacy character to the subject once and records the map', () => {
+ const store = openSqliteStore(':memory:')
+ store.players.upsertMany([player('p1', 'ovn:57', 0), player('p2', 'ovn:57', 1)])
+
+ expect(store.identity.legacyAccountKeys('ovn:')).toEqual(['ovn:57'])
+ expect(store.identity.adoptLegacyAccount('ovn:57', SUBJECT, 'network', 100)).toEqual({
+ moved: 2,
+ conflicts: 0,
+ })
+ const chars = store.players.listByToken(SUBJECT)
+ expect(chars.map((c) => [c.id, c.charSlot, c.subjectId])).toEqual([
+ ['p1', 0, SUBJECT],
+ ['p2', 1, SUBJECT],
+ ])
+ expect(store.players.listByToken('ovn:57')).toEqual([])
+ expect(store.identity.subjectForLegacy('ovn:57')).toBe(SUBJECT)
+ expect(store.identity.legacyAccountKeys('ovn:')).toEqual([])
+
+ // Idempotent: nothing left to move, the map is unchanged.
+ expect(store.identity.adoptLegacyAccount('ovn:57', SUBJECT, 'network', 200)).toEqual({
+ moved: 0,
+ conflicts: 0,
+ })
+ expect(store.players.listByToken(SUBJECT)).toHaveLength(2)
+ store.close()
+ })
+
+ it('never overwrites a slot the subject already uses', () => {
+ const store = openSqliteStore(':memory:')
+ store.players.upsertMany([
+ player('new0', SUBJECT, 0, { subjectId: SUBJECT }),
+ player('old0', 'ovn:9', 0),
+ player('old2', 'ovn:9', 2),
+ ])
+ expect(store.identity.adoptLegacyAccount('ovn:9', SUBJECT, 'network', 1)).toEqual({
+ moved: 1,
+ conflicts: 1,
+ })
+ expect(store.players.findByTokenSlot(SUBJECT, 0)?.id).toBe('new0')
+ expect(store.players.findByTokenSlot(SUBJECT, 2)?.id).toBe('old2')
+ // The conflicting character stays reachable under its legacy key.
+ expect(store.players.findByTokenSlot('ovn:9', 0)?.id).toBe('old0')
+ store.close()
+ })
+
+ it('refuses to re-home a legacy key to a different subject', () => {
+ const store = openSqliteStore(':memory:')
+ store.players.upsert(player('p1', 'ovn:1', 0))
+ store.identity.adoptLegacyAccount('ovn:1', SUBJECT, 'network', 1)
+ store.players.upsert(player('p9', 'ovn:1', 1))
+ expect(() => store.identity.adoptLegacyAccount('ovn:1', OTHER, 'network', 2)).toThrow(
+ /already mapped/,
+ )
+ // Nothing moved by the refused call.
+ expect(store.players.findById('p9')?.token).toBe('ovn:1')
+ store.close()
+ })
+
+ 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()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,355 @@
@@ -0,0 +1,355 @@
+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 (schema 12): the
+ * legacy identity map, 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 legacyRows = db.prepare<[string], { id: string; char_slot: number }>(
+ 'SELECT id, char_slot FROM players WHERE token = ? ORDER BY char_slot',
+ )
+ const moveRow = db.prepare('UPDATE players SET token = ?, subject_id = ? WHERE id = ?')
+ const recordMap = db.prepare(`
+ INSERT INTO identity_legacy_map (legacy_key, subject_id, source, moved, conflicts, adopted_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(legacy_key) DO UPDATE SET
+ moved = identity_legacy_map.moved + excluded.moved,
+ conflicts = excluded.conflicts
+ `)
+ const mapped = db.prepare<[string], { subject_id: string }>(
+ 'SELECT subject_id FROM identity_legacy_map WHERE legacy_key = ?',
+ )
+
+ const adopt = db.transaction(
+ (legacyKey: string, subjectId: string, source: string, now: number) => {
+ const existing = mapped.get(legacyKey)
+ if (existing && existing.subject_id !== subjectId) {
+ // A legacy key belongs to exactly one subject; a different answer
+ // later is a data problem to look at, never a silent re-home.
+ throw new Error(
+ `legacy key ${legacyKey} is already mapped to ${existing.subject_id}, not ${subjectId}`,
+ )
+ }
+ const taken = new Set(takenSlots.all(subjectId).map((r) => r.char_slot))
+ let moved = 0
+ let conflicts = 0
+ for (const row of legacyRows.all(legacyKey)) {
+ if (taken.has(row.char_slot)) {
+ conflicts++
+ continue
+ }
+ moveRow.run(subjectId, subjectId, row.id)
+ taken.add(row.char_slot)
+ moved++
+ }
+ recordMap.run(legacyKey, subjectId, source, moved, conflicts, now)
+ return { moved, conflicts }
+ },
+ )
+
+ return {
+ adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
+ adopt(legacyKey, subjectId, source, now),
+ subjectForLegacy(legacyKey) {
+ return mapped.get(legacyKey)?.subject_id ?? null
+ },
+ legacyAccountKeys(prefix) {
+ // substr, not LIKE: `_` and `%` in a prefix must not act as wildcards.
+ const rows = db
+ .prepare(
+ 'SELECT DISTINCT token FROM players WHERE substr(token, 1, length(@p)) = @p ORDER BY token',
+ )
+ .all({ p: prefix }) as { token: string }[]
+ return rows.map((r) => r.token)
+ },
+ }
+}
+
+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
+ },
+ }
+}
@@ -153,7 +153,7 @@ describe('sqlite store', () => {
@@ -153,7 +153,7 @@ describe('sqlite store', () => {
const loaded = store.constraints.loadAll()
const loaded = store.constraints.loadAll()
expect(loaded).toHaveLength(2)
expect(loaded).toHaveLength(2)
expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })
expect(loaded.find((c) => c.id === 'c2')?.params).toEqual({ length: 2 })
- expect(store.meta.get('schema_version')).toBe('11')
+ expect(store.meta.get('schema_version')).toBe('12')
// v9 armor column round-trips.
// v9 armor column round-trips.
store.players.upsert({
store.players.upsert({
...store.players.findByToken('tok_11111111')!,
...store.players.findByToken('tok_11111111')!,
@@ -9,6 +9,20 @@ import type {
@@ -9,6 +9,20 @@ import type {
PlayerRepository,
PlayerRepository,
WorldEntityRepository,
WorldEntityRepository,
} from '../repositories.js'
} 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
* SQLite implementation. Synchronous better-sqlite3 is intentional: batched
@@ -19,7 +33,7 @@ import type {
@@ -19,7 +33,7 @@ import type {
* a database is upgraded step by step inside a transaction per step.
* a database is upgraded step by step inside a transaction per step.
*/
*/
-const SCHEMA_VERSION = 11
+const SCHEMA_VERSION = 12
const BASE_SCHEMA = `
const BASE_SCHEMA = `
CREATE TABLE IF NOT EXISTS world_entities (
CREATE TABLE IF NOT EXISTS world_entities (
@@ -49,6 +63,7 @@ CREATE TABLE IF NOT EXISTS players (
@@ -49,6 +63,7 @@ CREATE TABLE IF NOT EXISTS players (
reputation TEXT NOT NULL DEFAULT '{}',
reputation TEXT NOT NULL DEFAULT '{}',
unlocks TEXT NOT NULL DEFAULT '[]',
unlocks TEXT NOT NULL DEFAULT '[]',
active_job TEXT,
active_job TEXT,
+ subject_id TEXT,
updated_at INTEGER NOT NULL
updated_at INTEGER NOT NULL
);
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_token_slot ON players (token, char_slot);
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_token_slot ON players (token, char_slot);
@@ -70,6 +85,77 @@ CREATE TABLE IF NOT EXISTS meta (
@@ -70,6 +85,77 @@ CREATE TABLE IF NOT EXISTS meta (
);
);
`
`
+/**
+ * Platform integration tables (schema 12): canonical identity, the mod
+ * registry and the Media mirror queue. Shared by the fresh-database path and
+ * the 11 -> 12 migration 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_legacy_map (
+ legacy_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. */
/** Migration from version N applies index N-1. Each runs in a transaction. */
const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
1: (db) => {
1: (db) => {
@@ -146,6 +232,12 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
@@ -146,6 +232,12 @@ const MIGRATIONS: Record<number, (db: Database.Database) => void> = {
ALTER TABLE players ADD COLUMN active_job TEXT;
ALTER TABLE players ADD COLUMN active_job TEXT;
`)
`)
},
},
+ 11: (db) => {
+ // Platform integration: canonical subject per account (ADR-0006), the
+ // legacy identity map, the mod registry and the Media mirror queue.
+ db.exec('ALTER TABLE players ADD COLUMN subject_id TEXT;')
+ db.exec(PLATFORM_SCHEMA)
+ },
}
}
interface WorldEntityRow {
interface WorldEntityRow {
@@ -182,6 +274,7 @@ interface PlayerRow {
@@ -182,6 +274,7 @@ interface PlayerRow {
reputation: string
reputation: string
unlocks: string
unlocks: string
active_job: string | null
active_job: string | null
+ subject_id: string | null
char_slot: number
char_slot: number
updated_at: number
updated_at: number
}
}
@@ -195,7 +288,7 @@ interface ConstraintRow {
@@ -195,7 +288,7 @@ interface ConstraintRow {
updated_at: number
updated_at: number
}
}
-export function openSqliteStore(path: string): PersistenceStore {
+export function openSqliteStore(path: string): SqlitePersistenceStore {
const db = new Database(path)
const db = new Database(path)
db.pragma('journal_mode = WAL')
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('synchronous = NORMAL')
@@ -206,6 +299,7 @@ export function openSqliteStore(path: string): PersistenceStore {
@@ -206,6 +299,7 @@ export function openSqliteStore(path: string): PersistenceStore {
if (!hasMeta) {
if (!hasMeta) {
// Fresh database: create the full current schema.
// Fresh database: create the full current schema.
db.exec(BASE_SCHEMA)
db.exec(BASE_SCHEMA)
+ db.exec(PLATFORM_SCHEMA)
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(
'schema_version',
'schema_version',
String(SCHEMA_VERSION),
String(SCHEMA_VERSION),
@@ -293,9 +387,10 @@ export function openSqliteStore(path: string): PersistenceStore {
@@ -293,9 +387,10 @@ export function openSqliteStore(path: string): PersistenceStore {
}
}
const upsertPlayer = db.prepare(`
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, 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, @updated_at)
+ 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
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,
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
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
`)
`)
@@ -320,6 +415,7 @@ export function openSqliteStore(path: string): PersistenceStore {
@@ -320,6 +415,7 @@ export function openSqliteStore(path: string): PersistenceStore {
activeJob: row.active_job ? (JSON.parse(row.active_job) as PlayerDto['activeJob']) : null,
activeJob: row.active_job ? (JSON.parse(row.active_job) as PlayerDto['activeJob']) : null,
stats: row.stats ? (JSON.parse(row.stats) as PlayerDto['stats']) : null,
stats: row.stats ? (JSON.parse(row.stats) as PlayerDto['stats']) : null,
charSlot: row.char_slot ?? 0,
charSlot: row.char_slot ?? 0,
+ ...(row.subject_id ? { subjectId: row.subject_id } : {}),
updatedAt: row.updated_at,
updatedAt: row.updated_at,
})
})
@@ -341,6 +437,7 @@ export function openSqliteStore(path: string): PersistenceStore {
@@ -341,6 +437,7 @@ export function openSqliteStore(path: string): PersistenceStore {
unlocks: JSON.stringify(p.unlocks ?? []),
unlocks: JSON.stringify(p.unlocks ?? []),
active_job: p.activeJob ? JSON.stringify(p.activeJob) : null,
active_job: p.activeJob ? JSON.stringify(p.activeJob) : null,
char_slot: p.charSlot ?? 0,
char_slot: p.charSlot ?? 0,
+ subject_id: p.subjectId ?? null,
updated_at: p.updatedAt,
updated_at: p.updatedAt,
})
})
@@ -448,11 +545,18 @@ export function openSqliteStore(path: string): PersistenceStore {
@@ -448,11 +545,18 @@ export function openSqliteStore(path: string): PersistenceStore {
}
}
return {
return {
+ db,
worldEntities,
worldEntities,
players,
players,
guests,
guests,
constraints,
constraints,
meta,
meta,
+ identity: createIdentityRepository(db),
+ mods: createModRepository(db),
+ mediaMirrors: createMediaMirrorRepository(db),
+ transaction<T>(fn: () => T): T {
+ return db.transaction(fn)()
+ },
close(): void {
close(): void {
db.close()
db.close()
},
},