OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / fca2ba8

Add composeSchemas — merge multiple schemas onto one Room
Lets a single Room (one Autobase + Hyperbee view) carry several op- domains, so a composite (e.g. an org Room that also handles issue ops) replicates everything on one autobase instead of conveying a separate sub-autobase's bootstrap key to every joiner. Reuses the first schema's JSON encodeOp/decodeOp; runs all validateOp + all apply (domain switches ignore foreign op.types). Composed op.types should be disjoint; a shared type runs all handlers (safe only when idempotent, e.g. set-meta). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fca2ba84960f576f77821acc7525cd4c147ddb07
by plankatron <fleeky@gmail.com> · 2026-05-27T01:40:30.000Z

Changed files (3) +123 -4

index.js +6 -4
……@@ -2,12 +2,14 @@
22 * hyper-rooms — multiwriter substrate built on Autobase + Hyperbee
33 *
44 * Exports:
5 * Room — the multiwriter room class
6 * jsonSchema — convenience builder for JSON-encoded schemas
7 * invites — bearer-invite encode/decode (shared by hyper-orgs
8 * `org invite` + hyper-issues `repo invite`)
5 * Room — the multiwriter room class
6 * jsonSchema — convenience builder for JSON-encoded schemas
7 * composeSchemas — merge several schemas onto one Room (linked-core composites)
8 * invites — bearer-invite encode/decode (shared by hyper-orgs
9 * `org invite` + hyper-issues `repo invite`)
910 */
1011
1112export { Room } from './lib/room.js'
1213export { jsonSchema } from './lib/json-schema.js'
14export { composeSchemas } from './lib/compose-schemas.js'
1315export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js'
lib/compose-schemas.js +48 -0
……@@ -0,0 +1,48 @@
1/**
2 * composeSchemas — combine multiple Room schemas into one.
3 *
4 * Lets a single Room (one Autobase + one Hyperbee view) carry several
5 * op-domains. Used for "linked-core" composites — e.g. an org Room that
6 * also handles issue ops, so issues replicate WITH the org autobase
7 * instead of living in a separate autobase whose bootstrap key must be
8 * conveyed to every joiner.
9 *
10 * All composed schemas must share the same op wire-encoding. jsonSchema
11 * uses plain JSON, which is op-agnostic, so we reuse the first schema's
12 * encodeOp/decodeOp. validateOp and apply run EVERY schema in order;
13 * each domain schema's switch ignores op.types it doesn't own (no
14 * default-throw), so they coexist.
15 *
16 * Constraint: op.types should be DISJOINT across composed schemas. A
17 * shared op.type runs ALL handlers — safe only when those writes are
18 * idempotent/compatible (e.g. two schemas both writing the same
19 * meta:<key> value). Diverging semantics on a shared type is a bug.
20 *
21 * @param {Array<{encodeOp,decodeOp,validateOp,apply}>} schemas
22 * @returns {{encodeOp,decodeOp,validateOp,apply}}
23 */
24export function composeSchemas (schemas) {
25 if (!Array.isArray(schemas) || schemas.length === 0) {
26 throw new Error('composeSchemas requires a non-empty array of schemas')
27 }
28 for (const s of schemas) {
29 for (const fn of ['encodeOp', 'decodeOp', 'validateOp', 'apply']) {
30 if (typeof s[fn] !== 'function') {
31 throw new Error('composeSchemas: each schema must provide ' + fn)
32 }
33 }
34 }
35
36 const [first] = schemas
37
38 return {
39 encodeOp: (op) => first.encodeOp(op),
40 decodeOp: (buf) => first.decodeOp(buf),
41 validateOp (op) {
42 for (const s of schemas) s.validateOp(op)
43 },
44 async apply (batch, op, host) {
45 for (const s of schemas) await s.apply(batch, op, host)
46 }
47 }
48}
test/compose-schemas.test.js +69 -0
……@@ -0,0 +1,69 @@
1import test from 'brittle'
2import Corestore from 'corestore'
3import { mkdtempSync } from 'fs'
4import { join } from 'path'
5import { tmpdir } from 'os'
6import { replicateAndSync } from 'autobase-test-helpers'
7import { Room, jsonSchema, composeSchemas } from '../index.js'
8
9function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-compose-')) }
10
11// Two disjoint toy op-domains, plus a shared idempotent op (set-meta) to
12// exercise the documented "shared op.type runs all handlers" behaviour.
13const alpha = jsonSchema({
14 validate (op) { if (op.type === 'put-a' && !op.id) throw new Error('put-a needs id') },
15 async apply (batch, op) {
16 if (op.type === 'put-a') await batch.put('a:' + op.id, { v: op.v })
17 else if (op.type === 'set-meta') await batch.put('meta:' + op.key, { value: op.value })
18 }
19})
20const beta = jsonSchema({
21 validate (op) { if (op.type === 'put-b' && !op.id) throw new Error('put-b needs id') },
22 async apply (batch, op) {
23 if (op.type === 'put-b') await batch.put('b:' + op.id, { v: op.v })
24 else if (op.type === 'set-meta') await batch.put('meta:' + op.key, { value: op.value })
25 }
26})
27
28test('composeSchemas: one Room carries two op-domains and replicates both', async (t) => {
29 const roomA = new Room(new Corestore(tmp()), {
30 schema: composeSchemas([alpha, beta]), namespace: 'shared'
31 })
32 await roomA.ready()
33
34 await roomA.append({ type: 'put-a', id: '1', v: 'aaa' })
35 await roomA.append({ type: 'put-b', id: '1', v: 'bbb' })
36 await roomA.append({ type: 'set-meta', key: 'k', value: 'm' })
37 await roomA.base.update()
38
39 const roomB = new Room(new Corestore(tmp()), {
40 schema: composeSchemas([alpha, beta]), namespace: 'shared', bootstrapKey: roomA.key
41 })
42 await roomB.ready()
43 await replicateAndSync([roomA.base, roomB.base])
44
45 const a = await roomB.view.get('a:1')
46 const b = await roomB.view.get('b:1')
47 const m = await roomB.view.get('meta:k')
48 t.is(a && a.value.v, 'aaa', 'domain A op replicated to B')
49 t.is(b && b.value.v, 'bbb', 'domain B op replicated to B')
50 t.is(m && m.value.value, 'm', 'shared set-meta applied once without error')
51
52 await roomA.close()
53 await roomB.close()
54})
55
56test('composeSchemas: validateOp runs every schema; cross-domain ops do not throw', async (t) => {
57 const room = new Room(new Corestore(tmp()), {
58 schema: composeSchemas([alpha, beta]), namespace: 's'
59 })
60 await room.ready()
61 // put-b is unknown to alpha's switch but valid for beta — must not throw.
62 await t.execution(room.append({ type: 'put-b', id: '2', v: 'x' }))
63 await room.close()
64})
65
66test('composeSchemas: rejects empty input', (t) => {
67 t.exception(() => composeSchemas([]))
68 t.exception(() => composeSchemas(null))
69})