import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import os from 'bare-os'
import path from 'bare-path'
import { randomBytes } from 'hypercore-crypto'
import * as Y from 'yjs'
import { notebookApply, encodeOp, decodeOp } from '../lib/multiwriter/notebook-apply.js'
function createStore () {
const tmp = path.join(os.tmpdir(), 'hypernote-test-' + randomBytes(16).toString('hex'))
return new Corestore(tmp)
}
async function createNotebook (store) {
const base = new Autobase(store, {
open: (store) => {
const core = store.get('view', { valueEncoding: 'json' })
return new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json' })
},
apply: notebookApply,
valueEncoding: 'binary'
})
await base.ready()
return base
}
test('notebook-apply: create-note', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'create-note',
noteId: 'note-1',
title: 'My First Note',
author: 'user-1',
tags: ['test', 'demo'],
timestamp: Date.now()
}))
await base.update()
const entry = await base.view.get('note:note-1')
t.ok(entry, 'note exists in view')
t.is(entry.value.title, 'My First Note')
t.is(entry.value.author, 'user-1')
t.alike(entry.value.tags, ['test', 'demo'])
await base.close()
await store.close()
})
test('notebook-apply: update-note-title', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'create-note',
noteId: 'note-1',
title: 'Original',
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
await base.append(encodeOp({
type: 'update-note-title',
noteId: 'note-1',
title: 'Updated Title',
timestamp: Date.now()
}))
await base.update()
const entry = await base.view.get('note:note-1')
t.is(entry.value.title, 'Updated Title')
await base.close()
await store.close()
})
test('notebook-apply: delete-note', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'create-note',
noteId: 'note-1',
title: 'To Delete',
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
const before = await base.view.get('note:note-1')
t.ok(before, 'note exists before delete')
await base.append(encodeOp({
type: 'delete-note',
noteId: 'note-1',
timestamp: Date.now()
}))
await base.update()
const after = await base.view.get('note:note-1')
t.absent(after, 'note gone after delete')
await base.close()
await store.close()
})
test('notebook-apply: yjs-update roundtrip', async (t) => {
const store = createStore()
const base = await createNotebook(store)
// Create a Y.Doc and generate an update
const doc = new Y.Doc()
doc.getText('content').insert(0, 'Hello world')
const update = Y.encodeStateAsUpdate(doc)
const updateB64 = Buffer.from(update).toString('base64')
await base.append(encodeOp({
type: 'create-note',
noteId: 'note-1',
title: 'Yjs Note',
author: 'user-1',
timestamp: Date.now()
}))
await base.append(encodeOp({
type: 'yjs-update',
noteId: 'note-1',
update: updateB64,
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
// Verify update stored at seq 0
const entry = await base.view.get('yjs:note-1:0')
t.ok(entry, 'yjs update stored')
t.is(entry.value.author, 'user-1')
// Reconstruct on another Y.Doc
const doc2 = new Y.Doc()
const updateBuf = Buffer.from(entry.value.update, 'base64')
Y.applyUpdate(doc2, new Uint8Array(updateBuf))
t.is(doc2.getText('content').toString(), 'Hello world', 'yjs content reconstructed')
await base.close()
await store.close()
})
test('notebook-apply: multiple yjs-updates get sequential keys', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'create-note',
noteId: 'note-1',
title: 'Seq Note',
author: 'user-1',
timestamp: Date.now()
}))
for (let i = 0; i < 3; i++) {
const doc = new Y.Doc()
doc.getText('content').insert(0, `Update ${i}`)
const update = Y.encodeStateAsUpdate(doc)
await base.append(encodeOp({
type: 'yjs-update',
noteId: 'note-1',
update: Buffer.from(update).toString('base64'),
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
}
const e0 = await base.view.get('yjs:note-1:0')
const e1 = await base.view.get('yjs:note-1:1')
const e2 = await base.view.get('yjs:note-1:2')
t.ok(e0, 'seq 0 exists')
t.ok(e1, 'seq 1 exists')
t.ok(e2, 'seq 2 exists')
const counter = await base.view.get('yjs-counter:note-1')
t.is(counter.value.seq, 3, 'counter at 3')
await base.close()
await store.close()
})
test('notebook-apply: add-media', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'add-media',
noteId: 'note-1',
hash: 'abc123def456',
mimeType: 'image/png',
filename: 'screenshot.png',
size: 12345,
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
const entry = await base.view.get('media:note-1:abc123def456')
t.ok(entry, 'media entry exists')
t.is(entry.value.mimeType, 'image/png')
t.is(entry.value.filename, 'screenshot.png')
t.is(entry.value.size, 12345)
await base.close()
await store.close()
})
test('notebook-apply: remove-media', async (t) => {
const store = createStore()
const base = await createNotebook(store)
await base.append(encodeOp({
type: 'add-media',
noteId: 'note-1',
hash: 'abc123',
mimeType: 'image/png',
filename: 'pic.png',
size: 100,
author: 'user-1',
timestamp: Date.now()
}))
await base.update()
const before = await base.view.get('media:note-1:abc123')
t.ok(before, 'media exists before remove')
await base.append(encodeOp({
type: 'remove-media',
noteId: 'note-1',
hash: 'abc123',
timestamp: Date.now()
}))
await base.update()
const after = await base.view.get('media:note-1:abc123')
t.absent(after, 'media gone after remove')
await base.close()
await store.close()
})
test('notebook-apply: yjs-snapshot', async (t) => {
const store = createStore()
const base = await createNotebook(store)
const doc = new Y.Doc()
doc.getText('content').insert(0, 'Snapshot content')
const state = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64')
await base.append(encodeOp({
type: 'yjs-snapshot',
noteId: 'note-1',
state,
timestamp: Date.now()
}))
await base.update()
const entry = await base.view.get('yjs-snapshot:note-1')
t.ok(entry, 'snapshot stored')
// Verify reconstruction
const doc2 = new Y.Doc()
Y.applyUpdate(doc2, new Uint8Array(Buffer.from(entry.value.state, 'base64')))
t.is(doc2.getText('content').toString(), 'Snapshot content')
await base.close()
await store.close()
})
test('notebook-apply: encodeOp / decodeOp roundtrip', async (t) => {
const op = { type: 'create-note', noteId: 'n1', title: 'Test', timestamp: 123 }
const encoded = encodeOp(op)
t.ok(Buffer.isBuffer(encoded))
const decoded = decodeOp(encoded)
t.is(decoded.type, 'create-note')
t.is(decoded.noteId, 'n1')
t.is(decoded.title, 'Test')
})