import test from 'brittle'
import Corestore from 'corestore'
import b4a from 'b4a'
import { replicateAndSync } from 'autobase-test-helpers'
import { Room, jsonSchema } from '../index.js'
// ── Test fixture: a tiny "todos" schema ──
const todosSchema = jsonSchema({
async apply (batch, op, host) {
if (op.type === 'add-todo') {
await batch.put(`todo:${op.id}`, {
id: op.id,
text: op.text,
done: false,
author: op.author || null,
createdAt: op.timestamp || Date.now()
})
} else if (op.type === 'check-todo') {
const entry = await batch.get(`todo:${op.id}`)
if (entry) {
await batch.put(`todo:${op.id}`, { ...entry.value, done: true })
}
} else if (op.type === 'delete-todo') {
await batch.del(`todo:${op.id}`)
}
},
validate (op) {
if (op.type === 'add-todo') {
if (typeof op.id !== 'number') throw new Error('add-todo: id must be number')
if (typeof op.text !== 'string') throw new Error('add-todo: text must be string')
}
}
})
async function makeRoom (t, opts = {}) {
const dir = '/tmp/hyper-rooms-test-' + Math.random().toString(36).slice(2)
const store = new Corestore(dir)
await store.ready()
const room = new Room(store, { schema: todosSchema, namespace: 'test', ...opts })
await room.ready()
t.teardown(async () => {
try { await room.close() } catch {}
try { await store.close() } catch {}
})
return { room, store, dir }
}
// ── Single-writer (basic) ──────────────────────────────────────
test('single-writer: append → view round-trip', async (t) => {
const { room } = await makeRoom(t)
await room.append({ type: 'add-todo', id: 1, text: 'buy milk' })
await room.append({ type: 'add-todo', id: 2, text: 'walk dog' })
await room.append({ type: 'check-todo', id: 1 })
await room.base.update()
const t1 = await room.view.get('todo:1')
const t2 = await room.view.get('todo:2')
t.is(t1.value.text, 'buy milk')
t.is(t1.value.done, true, 'check-todo applied')
t.is(t2.value.text, 'walk dog')
t.is(t2.value.done, false)
})
test('append + delete', async (t) => {
const { room } = await makeRoom(t)
await room.append({ type: 'add-todo', id: 1, text: 'temporary' })
await room.append({ type: 'delete-todo', id: 1 })
await room.base.update()
const t1 = await room.view.get('todo:1')
t.absent(t1, 'deleted')
})
test('schema validation rejects bad ops', async (t) => {
const { room } = await makeRoom(t)
await t.exception(
room.append({ type: 'add-todo', text: 'no id' }),
/id must be number/
)
await t.exception(
room.append({ type: 'add-todo', id: 1, text: 42 }),
/text must be string/
)
await t.exception(
room.append({ }), // no type
/op\.type must be a string/
)
})
test('append rejects writer ops directly', async (t) => {
const { room } = await makeRoom(t)
await t.exception(
room.append({ type: 'add-writer', userId: 'x' }),
/Use addWriter/
)
})
test('persistence across reopen', async (t) => {
const dir = '/tmp/hyper-rooms-persist-' + Math.random().toString(36).slice(2)
const store1 = new Corestore(dir)
await store1.ready()
const room1 = new Room(store1, { schema: todosSchema, namespace: 'test' })
await room1.ready()
const key = room1.keyHex
await room1.append({ type: 'add-todo', id: 99, text: 'persistent' })
await room1.base.update()
await room1.close()
await store1.close()
// Reopen
const store2 = new Corestore(dir)
await store2.ready()
const room2 = new Room(store2, { schema: todosSchema, namespace: 'test' })
await room2.ready()
t.is(room2.keyHex, key, 'same room key after reopen')
const entry = await room2.view.get('todo:99')
t.ok(entry, 'todo persisted')
t.is(entry.value.text, 'persistent')
await room2.close()
await store2.close()
})
// ── Writer management ─────────────────────────────────────────
test('addWriter / listWriters / removeWriter', async (t) => {
const { room } = await makeRoom(t)
// Use a fake writer key (32 bytes hex)
const aliceKey = '11'.repeat(32)
const bobKey = '22'.repeat(32)
await room.addWriter('alice', { writerKey: aliceKey, role: 'owner' })
await room.addWriter('bob', { writerKey: bobKey, role: 'writer' })
await room.base.update()
const writers = await room.listWriters()
t.is(writers.length, 2)
t.ok(writers.find(w => w.userId === 'alice' && w.role === 'owner'))
t.ok(writers.find(w => w.userId === 'bob' && w.role === 'writer'))
const alice = await room.getWriter('alice')
t.is(alice.role, 'owner')
t.is(alice.writerKey, aliceKey)
await room.removeWriter('bob')
await room.base.update()
const after = await room.listWriters()
t.is(after.length, 1)
t.is(after[0].userId, 'alice')
})
test('addWriter rejects bad role', async (t) => {
const { room } = await makeRoom(t)
await t.exception(
room.addWriter('x', { writerKey: '11'.repeat(32), role: 'admin' }),
/role must be/
)
})
test('addWriter requires writerKey', async (t) => {
const { room } = await makeRoom(t)
await t.exception(
room.addWriter('x', { role: 'writer' }),
/requires opts\.writerKey/
)
})
// ── Multi-writer replication ──────────────────────────────────
test('two rooms can replicate via stream pair', async (t) => {
// Room A creates the room, room B joins via the bootstrap key.
// Uses the canonical autobase replication pattern: base.replicate() +
// replicateAndSync() helper that polls until heads converge.
const dirA = '/tmp/hyper-rooms-mwA-' + Math.random().toString(36).slice(2)
const dirB = '/tmp/hyper-rooms-mwB-' + Math.random().toString(36).slice(2)
const storeA = new Corestore(dirA)
const storeB = new Corestore(dirB)
await storeA.ready()
await storeB.ready()
// A creates the room
const roomA = new Room(storeA, { schema: todosSchema, namespace: 'shared' })
await roomA.ready()
const bootstrapKey = roomA.keyHex
// A appends a todo
await roomA.append({ type: 'add-todo', id: 1, text: 'from A' })
await roomA.base.update()
// B joins the same room via the bootstrap key
const roomB = new Room(storeB, {
schema: todosSchema,
namespace: 'shared',
bootstrapKey
})
await roomB.ready()
// Sync both bases using the canonical helper.
// (This sets up replication streams and polls until heads converge.)
await replicateAndSync([roomA.base, roomB.base])
// B should now see A's todo
const entry = await roomB.view.get('todo:1')
t.ok(entry, 'B received A\'s todo via replication')
t.is(entry.value.text, 'from A')
t.teardown(async () => {
try { await roomA.close() } catch {}
try { await roomB.close() } catch {}
try { await storeA.close() } catch {}
try { await storeB.close() } catch {}
})
})
test('two writers: B added by A, both append, both see all', async (t) => {
// Full multi-writer test: A adds B as a writer, B appends, both see both.
const dirA = '/tmp/hyper-rooms-2wA-' + Math.random().toString(36).slice(2)
const dirB = '/tmp/hyper-rooms-2wB-' + Math.random().toString(36).slice(2)
const storeA = new Corestore(dirA)
const storeB = new Corestore(dirB)
await storeA.ready()
await storeB.ready()
const roomA = new Room(storeA, { schema: todosSchema, namespace: 'shared' })
await roomA.ready()
const bootstrapKey = roomA.keyHex
const roomB = new Room(storeB, {
schema: todosSchema,
namespace: 'shared',
bootstrapKey
})
await roomB.ready()
// First sync so B is connected and knows about the room structure
await replicateAndSync([roomA.base, roomB.base])
// A adds B as a writer (using B's local writer key)
await roomA.addWriter('bob', { writerKey: roomB.localKeyHex, role: 'writer' })
await replicateAndSync([roomA.base, roomB.base])
// B should now be writable
t.ok(roomB.writable, 'B is now writable after addWriter')
// Both append
await roomA.append({ type: 'add-todo', id: 1, text: 'from A' })
await roomB.append({ type: 'add-todo', id: 2, text: 'from B' })
// Sync and verify both see both
await replicateAndSync([roomA.base, roomB.base])
const entry1A = await roomA.view.get('todo:1')
const entry2A = await roomA.view.get('todo:2')
const entry1B = await roomB.view.get('todo:1')
const entry2B = await roomB.view.get('todo:2')
t.is(entry1A.value.text, 'from A', 'A sees own todo')
t.is(entry2A.value.text, 'from B', 'A sees B\'s todo')
t.is(entry1B.value.text, 'from A', 'B sees A\'s todo')
t.is(entry2B.value.text, 'from B', 'B sees own todo')
// Both should also see the writer entry
const writers = await roomA.listWriters()
t.ok(writers.find(w => w.userId === 'bob'), 'A has bob in writers')
t.teardown(async () => {
try { await roomA.close() } catch {}
try { await roomB.close() } catch {}
try { await storeA.close() } catch {}
try { await storeB.close() } catch {}
})
})
// ── jsonSchema helper ─────────────────────────────────────────
test('jsonSchema requires an apply function', (t) => {
t.exception(() => jsonSchema({}), /requires an apply function/)
})
test('jsonSchema validateOp catches non-object ops', async (t) => {
const schema = jsonSchema({ apply: async () => {} })
t.exception(() => schema.validateOp(null), /op must be an object/)
t.exception(() => schema.validateOp({}), /op\.type must be a string/)
})
test('Room rejects bad schema', (t) => {
const store = new Corestore('/tmp/hyper-rooms-bad-' + Math.random().toString(36).slice(2))
t.exception(
() => new Room(store, { schema: { encodeOp: () => {} } }),
/Schema missing required function/
)
t.exception(() => new Room(store, {}), /Room requires a schema/)
})