openvibers/openvibe.games Commits

Games: published creator worlds get their own page (/worlds/<id>) and are in OpenVibe.Search (#91)

OP OpenVibers committed 28 days ago · f8ea243621 · 13 files, +526 −20

- GET /worlds/<id>: a published world's server-rendered page in the OpenVibe Frame (title, maker,
  summary, visits, Join this world), the link to share; anything else is a real 404. /worlds links
  each published title to it.
- OpenVibe.Search: games.index_document.upserted|deleted (contracts 0.195.0) through
  openvibe-publishing/search-feed (1.4.0) and the event outbox, in the transaction of the change:
  publishing, renaming or unpublishing a world, staff taking it down, deleting it. The sequencer is
  games_index_revisions (migration 0011); a sweep a minute after boot and every six hours catches up.

Tests: searchIndex.test.ts (document, tombstones, an unchanged sweep sends nothing, payloads valid
against contracts 0.195.0, the page escapes its text).

Changes

ddiff --git a/apps/client/src/pages/worlds.ts b/apps/client/src/pages/worlds.ts
iindex 05ad42e..d5968e8 100644
---- a/apps/client/src/pages/worlds.ts
++++ b/apps/client/src/pages/worlds.ts
@@@ -82,8 +82,8 @@ function el<K extends keyof HTMLElementTagNameMap>(
return node
}
--function link(text: string, href: string, primary = false): HTMLAnchorElement {
-- const a = el('a', { className: primary ? 'button primary' : 'button', text })
++function link(text: string, href: string, primary = false, className?: string): HTMLAnchorElement {
++ const a = el('a', { className: className ?? (primary ? 'button primary' : 'button'), text })
a.href = href
return a
}
@@@ -99,6 +99,15 @@ function button(
return b
}
++/** A world's title: a link to its own page (/worlds/<id>) once it is published, plain text before. */
++function titleOf(world: World): HTMLElement {
++ const heading = el('h3')
++ if ((world.status ?? 'published') === 'published')
++ heading.append(link(world.title, `/worlds/${world.id}`, false, 'title'))
++ else heading.textContent = world.title
++ return heading
++}
++
const count = (n: number, one: string, many: string): string =>
`${n.toLocaleString()} ${n === 1 ? one : many}`
@@@ -112,7 +121,7 @@ function publishedCard(world: World, staff: boolean): HTMLElement {
const card = el(
'article',
{ className: 'card' },
-- el('h3', { text: world.title }),
++ titleOf(world),
el('p', { className: 'by', text: `by ${world.owner}` }),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
@@@ -137,7 +146,7 @@ function mineCard(world: World): HTMLElement {
'article',
{ className: 'card' },
el('span', { className: `badge ${status}`, text: label }),
-- el('h3', { text: world.title }),
++ titleOf(world),
world.summary ? el('p', { className: 'summary', text: world.summary }) : null,
el('p', { className: 'stats', text: stats(world) }),
)
ddiff --git a/apps/client/worlds.html b/apps/client/worlds.html
iindex 1f182d4..4a8d071 100644
---- a/apps/client/worlds.html
++++ b/apps/client/worlds.html
@@@ -107,6 +107,14 @@
font-weight: 800;
overflow-wrap: anywhere;
}
++ .card h3 a.title {
++ color: inherit;
++ text-decoration: none;
++ }
++ .card h3 a.title:hover,
++ .card h3 a.title:focus-visible {
++ text-decoration: underline;
++ }
.card .by,
.card .stats {
margin: 0;
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex e578641..1dee0eb 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -22,6 +22,7 @@
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.0",
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 7225bf8..45c50fb 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -87,6 +87,8 @@ import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CreatorWorldHost } from './worlds/worldHost.js'
import { createWorldHubStarter } from './worlds/startWorldHub.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
++import { createWorldPage } from './worlds/worldPage.js'
++import { createWorldSearchIndex } from './worlds/searchIndex.js'
/**
* Dedicated authoritative server entry point.
@@@ -758,6 +760,19 @@ async function main(): Promise<void> {
// ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
const clientAddress = (req: IncomingMessage): string =>
resolveClientAddress(req, config.trustProxy)
++ // Published creator worlds in OpenVibe.Search (worlds/searchIndex.ts): through the outbox, so only with events on.
++ const gamesSiteUrl = config.oauth?.selfUrl ?? 'https://openvibe.games'
++ const worldSearch = outbox
++ ? createWorldSearchIndex({
++ db,
++ outbox,
++ baseUrl: gamesSiteUrl,
++ log: log.child({ system: 'world-search' }),
++ })
++ : null
++ worldSearch?.start()
++ // GET /worlds/<id>: a published world's own page (the one Search links to).
++ const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })
// Creator worlds' JSON API (worlds/worldRoutes.ts). State changes run in the router's admission chain.
const serveWorlds = createWorldRoutes({
store: creatorWorlds,
@@@ -784,7 +799,8 @@ async function main(): Promise<void> {
address: clientAddress,
log: log.child({ system: 'worlds' }),
perPerson: config.creatorWorldsPerPerson,
-- events: { db, sink, siteUrl: config.oauth?.selfUrl ?? 'https://openvibe.games' },
++ events: { db, sink, siteUrl: gamesSiteUrl },
++ ...(worldSearch ? { search: worldSearch } : {}),
})
// The casual games' JSON API (plan T22).
const serveCasual = createCasualRoutes({
@@@ -892,6 +908,7 @@ async function main(): Promise<void> {
}
if (serveCasual(req, res)) return true
if (serveWorlds(req, res)) return true
++ if (serveWorldPage(req, res)) return true
if (
handleGameRead(req, res, {
db,
@@@ -1121,6 +1138,7 @@ async function main(): Promise<void> {
clearInterval(metricsTimer)
clearInterval(heartbeatTimer)
clearInterval(worldSweepTimer)
++ worldSearch?.stop()
clearInterval(pruneTimer)
editors.stop()
const mirrorDone = mirror?.stop()
ddiff --git a/apps/server/src/worlds/searchIndex.test.ts b/apps/server/src/worlds/searchIndex.test.ts
nnew file mode 100644
iindex 0000000..21a1eee
---- /dev/null
++++ b/apps/server/src/worlds/searchIndex.test.ts
@@@ -0,0 +1,142 @@
++import { createRequire } from 'node:module'
++import { emptyMapV2 } from '@openvibe/content'
++import { createCreatorWorldStore } from '@openvibe/persistence'
++import { openTestDb } from '@openvibe/persistence/testing'
++import type { Tx } from 'openvibe-sdk/db'
++import { describe, expect, it } from 'vitest'
++import { createWorldSearchIndex, describeWorld } from './searchIndex.js'
++import { worldPageHtml } from './worldPage.js'
++
++const req = createRequire(import.meta.url)
++const { validate } = req('openvibe-contracts') as {
++ validate(id: string, v: unknown): { valid: boolean; errors?: unknown }
++}
++const KIM = 'usr_01J0000000000000000000000K'
++const silent = { info: () => {}, warn: () => {} }
++
++interface Sent {
++ event_type: string
++ payload: Record<string, unknown>
++}
++
++describe('creator worlds in OpenVibe.Search', () => {
++ it('describes a world as its page does, and a draft as a tombstone', () => {
++ const row = {
++ id: 'wk3j9x0a2bq',
++ owner_name: 'kim',
++ title: 'Moon base',
++ summary: '',
++ status: 'published',
++ published_at: 1_000,
++ updated_at: 2_000,
++ }
++ expect(describeWorld(row)).toMatchObject({
++ listed: true,
++ title: 'Moon base',
++ summary: 'A creator world by kim on OpenVibe.Games.',
++ facets: { kind: 'creator_world', owner: 'kim' },
++ })
++ expect(describeWorld({ ...row, status: 'draft' }).listed).toBe(false)
++ expect(describeWorld({ ...row, status: 'hidden' }).listed).toBe(false)
++ })
++
++ it('sends a published world, a tombstone when it is unpublished or deleted, and nothing for a draft', async () => {
++ const db = await openTestDb()
++ const store = createCreatorWorldStore(db)
++ const sent: Sent[] = []
++ const search = createWorldSearchIndex({
++ db,
++ outbox: {
++ enqueue: async (_t: Tx, event: unknown) => {
++ sent.push(event as Sent)
++ },
++ },
++ baseUrl: 'https://openvibe.games',
++ log: silent,
++ })
++ const draft = await store.create({
++ ownerSubject: KIM,
++ ownerName: 'kim',
++ title: 'Draft',
++ summary: '',
++ map: emptyMapV2(),
++ now: 1,
++ })
++ await search.sweep()
++ expect(sent).toEqual([])
++
++ const world = await store.create({
++ ownerSubject: KIM,
++ ownerName: 'kim',
++ title: 'Moon base',
++ summary: 'Low gravity.',
++ map: emptyMapV2(),
++ now: 2,
++ })
++ const published = await store.update(world.id, { status: 'published' }, 3)
++ if (!published) throw new Error('no world')
++ await db.tx((t) => search.sync(t, published))
++ expect(sent.map((e) => e.event_type)).toEqual(['games.index_document.upserted'])
++ const doc = sent[0]?.payload ?? {}
++ expect(doc).toMatchObject({
++ owner: 'games',
++ type: 'world',
++ id: world.id,
++ deleted: false,
++ canonical_url: `https://openvibe.games/worlds/${world.id}`,
++ title: 'Moon base',
++ summary: 'Low gravity.',
++ })
++ expect(validate('games.index_document.upserted@1', doc)).toMatchObject({ valid: true })
++
++ // Unchanged: the sweep sends nothing new. Unpublished: a tombstone. Deleted afterwards: nothing more.
++ await search.sweep()
++ expect(sent).toHaveLength(1)
++ const unpublished = await store.update(world.id, { status: 'draft' }, 4)
++ if (!unpublished) throw new Error('no world')
++ await db.tx((t) => search.sync(t, unpublished))
++ expect(sent.map((e) => e.event_type)).toEqual([
++ 'games.index_document.upserted',
++ 'games.index_document.deleted',
++ ])
++ expect(validate('games.index_document.deleted@1', sent[1]?.payload)).toMatchObject({
++ valid: true,
++ })
++
++ // A published world whose row disappears without a sync gets its tombstone from the sweep.
++ const again = await store.update(world.id, { status: 'published' }, 5)
++ if (!again) throw new Error('no world')
++ await db.tx((t) => search.sync(t, again))
++ await store.remove(world.id)
++ await search.sweep()
++ expect(sent.at(-1)?.event_type).toBe('games.index_document.deleted')
++ expect(sent.some((e) => e.payload.id === draft.id)).toBe(false)
++ })
++
++ it("renders a world's page with its text escaped", () => {
++ const shell = req('openvibe-shared/shell') as { page(o: Record<string, unknown>): string }
++ const html = worldPageHtml(
++ {
++ id: 'wk3j9x0a2bq',
++ ownerSubject: KIM,
++ ownerName: 'kim <b>',
++ title: 'Moon <script>',
++ summary: 'Low & slow',
++ status: 'published',
++ revision: 'r',
++ plays: 1,
++ createdAt: 1,
++ updatedAt: 2,
++ publishedAt: 2,
++ },
++ 'https://openvibe.games',
++ shell,
++ )
++ expect(html).toContain('Moon &lt;script&gt;')
++ expect(html).not.toContain('Moon <script>')
++ expect(html).toContain('by kim &lt;b&gt;')
++ expect(html).toContain('href="/play?world=wk3j9x0a2bq"')
++ expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')
++ expect(html).toContain('1 visit')
++ })
++})
ddiff --git a/apps/server/src/worlds/searchIndex.ts b/apps/server/src/worlds/searchIndex.ts
nnew file mode 100644
iindex 0000000..5692b15
---- /dev/null
++++ b/apps/server/src/worlds/searchIndex.ts
@@@ -0,0 +1,156 @@
++import { createRequire } from 'node:module'
++import type { CreatorWorld } from '@openvibe/persistence'
++import type { Db, Tx } from 'openvibe-sdk/db'
++import { sql } from 'openvibe-sdk/db'
++
++/**
++ * Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): one
++ * search.index-document@1 per published world (/worlds/<id>), sent as games.index_document.upserted|deleted through
++ * Games' event outbox in the transaction of the change. A world that is a draft, hidden or gone is a tombstone. The
++ * document is what the world's page says: its title, who made it and their summary. The sequencer
++ * (games_index_revisions) sends nothing for an unchanged document, so the sweep (a minute after boot, then every six
++ * hours) is cheap and catches anything a write missed.
++ */
++
++interface SearchFeed {
++ sync(t: Tx, type: string, row: unknown): Promise<unknown>
++ remove(t: Tx, type: string, id: string): Promise<unknown>
++ sweep(): Promise<Record<string, { seen: number; sent: number; removed: number; failed: number }>>
++}
++interface SearchFeedModule {
++ createSearchFeed(options: Record<string, unknown>): SearchFeed
++}
++
++const START_DELAY_MS = 60_000
++const INTERVAL_MS = 6 * 3_600_000
++
++interface Row {
++ id: string
++ owner_name: string
++ title: string
++ summary: string
++ status: string
++ published_at: number | string | null
++ updated_at: number | string
++}
++
++/** One world → what its /worlds/<id> page says. */
++export function describeWorld(row: Row) {
++ const summary = row.summary || `A creator world by ${row.owner_name} on OpenVibe.Games.`
++ return {
++ listed: row.status === 'published',
++ title: row.title,
++ summary,
++ body: [
++ row.title,
++ row.summary,
++ `Made by ${row.owner_name}.`,
++ 'A creator world on OpenVibe.Games: join it from your browser, no download.',
++ ]
++ .filter(Boolean)
++ .join('\n'),
++ facets: { kind: 'creator_world', owner: row.owner_name },
++ authorship: { mode: 'human' },
++ publishedAt:
++ row.published_at === null ? null : new Date(Number(row.published_at)).toISOString(),
++ updatedAt: new Date(Number(row.updated_at)).toISOString(),
++ }
++}
++
++export interface WorldSearchIndex {
++ /** Index (or tombstone) a world in the transaction that changed it. */
++ sync(t: Tx, world: CreatorWorld): Promise<void>
++ /** Tombstone a world in the transaction that deletes it. */
++ remove(t: Tx, id: string): Promise<void>
++ sweep(): Promise<void>
++ start(): void
++ stop(): void
++}
++
++export function createWorldSearchIndex(opts: {
++ db: Db
++ outbox: { enqueue(t: Tx, event: unknown, opts?: unknown): Promise<unknown>; kick?(): unknown }
++ baseUrl: string
++ log: {
++ info(msg: string, f?: Record<string, unknown>): void
++ warn(msg: string, f?: Record<string, unknown>): void
++ }
++ require?: NodeRequire
++}): WorldSearchIndex {
++ const req = opts.require ?? createRequire(import.meta.url)
++ const { createSearchFeed } = req('openvibe-publishing/search-feed') as SearchFeedModule
++ const columns = sql`id, owner_name, title, summary, status, published_at, updated_at`
++ const feed = createSearchFeed({
++ owner: 'games',
++ db: opts.db,
++ outbox: opts.outbox,
++ baseUrl: opts.baseUrl,
++ log: { log: (m: string) => opts.log.info(m), warn: (m: string) => opts.log.warn(m) },
++ types: {
++ world: {
++ page: (row: Row) => `/worlds/${row.id}`,
++ document: describeWorld,
++ rows: (after: string, limit: number) =>
++ opts.db.many<Row>(
++ sql`SELECT ${columns} FROM creator_worlds WHERE id > ${after} ORDER BY id LIMIT ${limit}`,
++ ),
++ exists: async (ids: string[]) =>
++ ids.length
++ ? (
++ await opts.db.many<{ id: string }>(
++ sql`SELECT id FROM creator_worlds WHERE id IN (${sql.join(ids.map((id) => sql`${id}`))})`,
++ )
++ ).map((r) => r.id)
++ : [],
++ },
++ },
++ })
++ const timers: ReturnType<typeof setTimeout>[] = []
++ const toRow = (w: CreatorWorld): Row => ({
++ id: w.id,
++ owner_name: w.ownerName,
++ title: w.title,
++ summary: w.summary,
++ status: w.status,
++ published_at: w.publishedAt,
++ updated_at: w.updatedAt,
++ })
++ const sweep = async (): Promise<void> => {
++ const res = await feed.sweep()
++ const w = res.world
++ if (w && (w.sent || w.removed || w.failed))
++ opts.log.info('search sweep', {
++ sent: w.sent,
++ removed: w.removed,
++ failed: w.failed,
++ seen: w.seen,
++ })
++ void opts.outbox.kick?.()
++ }
++ const quietly = (): void => {
++ sweep().catch((err: unknown) => opts.log.warn('search sweep failed', { error: String(err) }))
++ }
++ return {
++ sync: async (t, world) => {
++ await feed.sync(t, 'world', toRow(world))
++ },
++ remove: async (t, id) => {
++ await feed.remove(t, 'world', id)
++ },
++ sweep,
++ start: () => {
++ if (timers.length) return
++ const first = setTimeout(quietly, START_DELAY_MS)
++ const every = setInterval(quietly, INTERVAL_MS)
++ first.unref()
++ every.unref()
++ timers.push(first, every)
++ },
++ stop: () => {
++ for (const t of timers.splice(0)) {
++ clearTimeout(t)
++ clearInterval(t)
++ }
++ },
++ }
++}
ddiff --git a/apps/server/src/worlds/worldPage.ts b/apps/server/src/worlds/worldPage.ts
nnew file mode 100644
iindex 0000000..8f4d380
---- /dev/null
++++ b/apps/server/src/worlds/worldPage.ts
@@@ -0,0 +1,123 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createRequire } from 'node:module'
++import { CREATOR_WORLD_ID } from '@openvibe/content'
++import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
++import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'
++
++/**
++ * GET /worlds/<id>: a published creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page
++ * OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have
++ * visited, and the button that opens it in /play. Anything not published is a real 404 (a draft is its owner's, and
++ * the owner reaches it from /worlds).
++ */
++interface Shell {
++ page(o: Record<string, unknown>): string
++}
++
++const SITE_NAME = 'OpenVibe.Games'
++const NETWORK_URL = 'https://openvibe.network'
++const LINKS: NotFoundLink[] = [
++ { href: '/worlds', label: 'Creator worlds' },
++ { href: '/', label: 'OpenVibe.Games home' },
++]
++
++const STYLE = `<style>
++.world{max-width:760px;margin:0 auto;padding:28px 16px 64px}
++.world .eyebrow{margin:0 0 6px;font-size:12px;font-weight:800;letter-spacing:.14em;color:var(--secondary,#22d3ee)}
++.world h1{margin:0 0 6px;font-size:clamp(28px,5vw,40px);font-weight:900;overflow-wrap:anywhere}
++.world .by,.world .stats{margin:0 0 10px;color:var(--text-dim,#96a7c2)}
++.world .summary{margin:14px 0;font-size:18px;overflow-wrap:anywhere}
++.world .row{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}
++.world .play{display:inline-flex;align-items:center;min-height:44px;padding:10px 26px;border-radius:12px;background:var(--accent-strong,#2563eb);color:var(--on-accent-strong,#fff);font-weight:800;text-decoration:none}
++.world .more{display:inline-flex;align-items:center;min-height:44px;padding:10px 18px;border-radius:12px;border:1px solid var(--border,#1f2d47);color:inherit;text-decoration:none}
++.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}
++</style>`
++
++export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell): string {
++ const url = `${siteUrl}/worlds/${world.id}`
++ const description =
++ world.summary ||
++ `A creator world by ${world.ownerName} on OpenVibe.Games. Join it from your browser.`
++ const visits = `${world.plays.toLocaleString('en-US')} ${world.plays === 1 ? 'visit' : 'visits'}`
++ const links = [
++ { label: 'Creator worlds', href: '/worlds' },
++ { label: 'Play Scraplandia', href: '/play' },
++ ]
++ return shell.page({
++ name: SITE_NAME,
++ service: 'games',
++ lang: 'en',
++ title: `${world.title} · Creator world · ${SITE_NAME}`,
++ siteName: SITE_NAME,
++ description: description.slice(0, 300),
++ canonical: url,
++ robots: 'index, follow',
++ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,
++ navbar: {
++ service: 'games',
++ apiBase: NETWORK_URL,
++ links,
++ history: { type: 'page', title: world.title },
++ },
++ footer: {
++ service: 'games',
++ variant: 'full',
++ mount: '#ov-footer',
++ brandName: SITE_NAME,
++ updates: '/updates',
++ },
++ home: '/',
++ navLinks: links,
++ body: `<div id="navbar-mount"></div>
++<main id="main" class="world">
++ <p class="eyebrow">CREATOR WORLD</p>
++ <h1>${escapeHtml(world.title)}</h1>
++ <p class="by">by ${escapeHtml(world.ownerName)}</p>
++ ${world.summary ? `<p class="summary">${escapeHtml(world.summary)}</p>` : ''}
++ <p class="stats">${escapeHtml(visits)}</p>
++ <div class="row">
++ <a class="play" href="/play?world=${world.id}">▶ Join this world</a>
++ <a class="more" href="/worlds">More worlds</a>
++ </div>
++ <p class="note">Plays in your browser with no download, as a guest or with your OpenVibe account. A world is a
++ sandbox: what you gather or build inside it stays in it.</p>
++</main>`,
++ })
++}
++
++export function createWorldPage(opts: {
++ store: CreatorWorldStore
++ siteUrl: string
++ require?: NodeRequire
++}) {
++ const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell
++ const siteUrl = opts.siteUrl.replace(/\/+$/, '')
++ return (req: IncomingMessage, res: ServerResponse): boolean => {
++ const path = (req.url ?? '/').split('?')[0] ?? '/'
++ const match = /^\/worlds\/([^/]+)$/.exec(path)
++ if (!match || (req.method !== 'GET' && req.method !== 'HEAD')) return false
++ const id = match[1] ?? ''
++ void (async () => {
++ const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
++ if (!world || world.status !== 'published') {
++ sendNotFound(req, res, path, LINKS)
++ return
++ }
++ const body = worldPageHtml(world, siteUrl, shell)
++ res.writeHead(200, {
++ 'content-type': 'text/html; charset=utf-8',
++ 'cache-control': 'public, max-age=60',
++ 'content-length': String(Buffer.byteLength(body)),
++ })
++ res.end(req.method === 'HEAD' ? undefined : body)
++ })().catch(() => {
++ if (res.headersSent) return
++ res.writeHead(503, {
++ 'content-type': 'text/plain; charset=utf-8',
++ 'cache-control': 'no-store',
++ })
++ res.end('world unavailable')
++ })
++ return true
++ }
++}
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 99df418..d18e797 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -19,6 +19,7 @@ import {
} from '../platform/gameEvents.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
++import type { WorldSearchIndex } from './searchIndex.js'
/**
* /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
@@@ -61,6 +62,8 @@ export interface WorldRouteOptions {
* world's first publication, games.moderation.action when staff hide, restore or delete someone else's world.
*/
events?: { db: Db; sink: EventSink; siteUrl: string }
++ /** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
++ search?: WorldSearchIndex
now?: () => number
}
@@@ -177,12 +180,12 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const online = (id: string): number => opts.host.online().get(id) ?? 0
/** Runs a change with its events in one transaction (or without events when the outbox is off). */
const withEvents = <T>(
-- fn: (enqueue: (event: EventInput) => Promise<void>) => Promise<T>,
++ fn: (enqueue: (event: EventInput) => Promise<void>, t: Tx | null) => Promise<T>,
): Promise<T> => {
const events = opts.events?.sink.enabled ? opts.events : null
return events
-- ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event)))
-- : fn(async () => {})
++ ? events.db.tx((t) => fn((event) => events.sink.enqueue(t, event), t))
++ : fn(async () => {}, null)
}
const send = (
@@@ -399,9 +402,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
}
patch.status = status as CreatorWorldStatus
}
-- const updated = await withEvents(async (enqueue) => {
++ const updated = await withEvents(async (enqueue, t) => {
const next = await opts.store.update(id, patch, now())
if (!next) return null
++ // Search follows what the page shows: a published world's title and summary, or a tombstone.
++ if (t && opts.search) await opts.search.sync(t, next)
if (mine && patch.status === 'published' && world.publishedAt === null)
await enqueue(worldPublishedEvent(next, opts.events?.siteUrl ?? ''))
if (!mine && patch.status && patch.status !== world.status)
@@@ -440,17 +445,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const events = opts.events?.sink.enabled ? opts.events : null
await opts.host.remove(
id,
-- byStaff && events
-- ? (t) =>
-- events.sink.enqueue(
-- t,
-- moderationEvent(
-- 'world.deleted',
-- { type: 'world', id, ownerSubject: world.ownerSubject },
-- actorFor(user.subjectId),
-- { details: { status: world.status } },
-- ),
-- )
++ events
++ ? async (t) => {
++ await opts.search?.remove(t, id)
++ if (byStaff)
++ await events.sink.enqueue(
++ t,
++ moderationEvent(
++ 'world.deleted',
++ { type: 'world', id, ownerSubject: world.ownerSubject },
++ actorFor(user.subjectId),
++ { details: { status: world.status } },
++ ),
++ )
++ }
: undefined,
)
opts.log.info('creator world deleted', {
ddiff --git a/docs/games.md b/docs/games.md
iindex 4768926..0b43355 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -64,6 +64,10 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
`games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someone
else's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow an
account merge and go with an account deletion.
++- **Pages and Search:** a published world has its own page at `/worlds/<id>` (server-rendered, the link to share)
++ and is an OpenVibe.Search document (`games.index_document.upserted`, contracts 0.195.0, through
++ `openvibe-publishing/search-feed` and the event outbox; sequencer `games_index_revisions`, migration 0011). A
++ draft, hidden or deleted world is a tombstone; a sweep a minute after boot and every six hours catches up.
## Adding another game
ddiff --git a/packages/persistence/migrations/0011_world_search_index.sql b/packages/persistence/migrations/0011_world_search_index.sql
nnew file mode 100644
iindex 0000000..9ddc0cc
---- /dev/null
++++ b/packages/persistence/migrations/0011_world_search_index.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- Creator worlds in OpenVibe.Search (openvibe-publishing/search-feed, contracts 0.195.0): the index sequencer, the
++-- last revision and content hash sent for each document (owner games, type world). The events go out through the
++-- existing event_outbox, in the transaction of the change they describe.
++CREATE TABLE games_index_revisions (
++ owner text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ hash text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (owner, type, id)
++);
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 50ad5d1..d59a0e0 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -22,6 +22,7 @@ const TABLES = [
'casual_actions',
'play_sync',
'creator_worlds',
++ 'games_index_revisions',
'game_instances',
'character_instance_positions',
'world_meta',
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 9d5af15..13d6c95 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -126,6 +126,9 @@ importers:
openvibe-contracts:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.195.0
++ openvibe-publishing:
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1)
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])
@@@ -1347,6 +1350,17 @@ packages:
engines: {node: '>=20'}
hasBin: true
++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0}
++ version: 1.4.0
++ engines: {node: '>=20'}
++ peerDependencies:
++ openvibe-sdk: '>=0.15.0'
++ openvibe-shared: '>=1.5.0'
++ peerDependenciesMeta:
++ openvibe-shared:
++ optional: true
++
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0:
resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0}
version: 0.46.0
@@@ -2771,6 +2785,12 @@ snapshots:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
++ openvibe-publishing@https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0(openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]))(openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1):
++ dependencies:
++ openvibe-sdk: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected])
++ optionalDependencies:
++ openvibe-shared: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1
++
openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0(@electric-sql/[email protected])([email protected])([email protected]):
optionalDependencies:
'@electric-sql/pglite': 0.5.8
ddiff --git a/test/apps__server__src__worlds__searchIndex.test.js b/test/apps__server__src__worlds__searchIndex.test.js
nnew file mode 100644
iindex 0000000..2e5fd06
---- /dev/null
++++ b/test/apps__server__src__worlds__searchIndex.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/worlds/searchIndex.test.ts'
++// covers: 'apps/server/src/worlds'
++// Generated by node test/run.js --index; do not edit.