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

OP OpenVibers committed 23 Sept 2026 · 9d5997edaf · 7 files, +842 −8


Changes

ddiff --git a/packages/persistence/src/dto.ts b/packages/persistence/src/dto.ts
iindex 1aefb6a..7652047 100644
---- a/packages/persistence/src/dto.ts
++++ b/packages/persistence/src/dto.ts
@@@ -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?: string
name: string
pos: [number, number, number]
yaw: number
@@@ -68,3 +74,79 @@ export interface ConstraintDto {
params: Record<string, unknown> | null
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
++}
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 0bc1815..0633fd7 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -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 | null
set(key: string, value: string): void
@@@ -50,5 +126,14 @@ export interface PersistenceStore {
guests: GuestRepository
readonly constraints: ConstraintRepository
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
}
ddiff --git a/packages/persistence/src/sqlite/index.ts b/packages/persistence/src/sqlite/index.ts
iindex 391003b..33935c1 100644
---- a/packages/persistence/src/sqlite/index.ts
++++ b/packages/persistence/src/sqlite/index.ts
@@@ -1 +1 @@
--export { openSqliteStore } from './sqliteStore.js'
++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
nnew file mode 100644
iindex 0000000..66050a6
---- /dev/null
++++ b/packages/persistence/src/sqlite/platformRepositories.test.ts
@@@ -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()
++ })
++})
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.ts b/packages/persistence/src/sqlite/platformRepositories.ts
nnew file mode 100644
iindex 0000000..c692307
---- /dev/null
++++ b/packages/persistence/src/sqlite/platformRepositories.ts
@@@ -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
++ },
++ }
++}
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.test.ts b/packages/persistence/src/sqlite/sqliteStore.test.ts
iindex b41ec24..b759e38 100644
---- a/packages/persistence/src/sqlite/sqliteStore.test.ts
++++ b/packages/persistence/src/sqlite/sqliteStore.test.ts
@@@ -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')!,
ddiff --git a/packages/persistence/src/sqlite/sqliteStore.ts b/packages/persistence/src/sqlite/sqliteStore.ts
iindex 5d68cdf..adc255e 100644
---- a/packages/persistence/src/sqlite/sqliteStore.ts
++++ b/packages/persistence/src/sqlite/sqliteStore.ts
@@@ -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 = 12
const 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: string
unlocks: string
active_job: string | null
++ subject_id: string | null
char_slot: number
updated_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()
},