| … | … | @@ -2,12 +2,14 @@ |
| 2 | 2 | * hyper-rooms — multiwriter substrate built on Autobase + Hyperbee |
| 3 | 3 | * |
| 4 | 4 | * 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`) | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | export { Room } from './lib/room.js' |
| 12 | 13 | export { jsonSchema } from './lib/json-schema.js' |
| 14 | export { composeSchemas } from './lib/compose-schemas.js' | |
| 13 | 15 | export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js' |
| … | … | @@ -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 | */ | |
| 24 | export 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 | } |
| … | … | @@ -0,0 +1,69 @@ |
| 1 | import test from 'brittle' | |
| 2 | import Corestore from 'corestore' | |
| 3 | import { mkdtempSync } from 'fs' | |
| 4 | import { join } from 'path' | |
| 5 | import { tmpdir } from 'os' | |
| 6 | import { replicateAndSync } from 'autobase-test-helpers' | |
| 7 | import { Room, jsonSchema, composeSchemas } from '../index.js' | |
| 8 | ||
| 9 | function 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. | |
| 13 | const 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 | }) | |
| 20 | const 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 | ||
| 28 | test('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 | ||
| 56 | test('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 | ||
| 66 | test('composeSchemas: rejects empty input', (t) => { | |
| 67 | t.exception(() => composeSchemas([])) | |
| 68 | t.exception(() => composeSchemas(null)) | |
| 69 | }) |