event-storage
Version:
An optimized embedded event store for node.js
592 lines (482 loc) • 20.5 kB
JavaScript
const expect = require('expect.js');
const fs = require('fs-extra');
const Index = require('../src/Index');
const dataDirectory = __dirname + '/data';
describe('Index', function() {
let index, counter = 1, readers = [];
beforeEach(function() {
fs.emptyDirSync(dataDirectory);
});
afterEach(function() {
if (index) index.close();
for (let reader of readers) reader.close();
readers = [];
index = null;
});
function createIndex(name = 'test.index', options = {}) {
return new Index(name, Object.assign({ dataDirectory }, options));
}
function setupIndexWithEntries(num, indexMapper, options) {
if (typeof indexMapper === 'object') {
options = indexMapper;
indexMapper = null;
}
index = createIndex('test' + (counter++) + '.index', options);
for (let i = 1; i <= num; i++) {
index.add(new Index.Entry(indexMapper && indexMapper(i) || i, i));
}
index.flush();
return index;
}
function createReader(name, options) {
let reader = new Index.ReadOnly(name, Object.assign({ dataDirectory }, options));
readers[readers.length] = reader;
return reader;
}
it('is opened on instantiation', function() {
index = setupIndexWithEntries();
expect(index.isOpen()).to.be(true);
});
it('defaults name to ".index"', function() {
index = new Index({ dataDirectory });
expect(index.name).to.be('.index');
});
it('recovers metadata on reopening', function() {
index = createIndex('.index', { metadata: { test: 'valueStays' } });
expect(index.metadata.test).to.be('valueStays');
index.close();
index = createIndex(index.name);
expect(index.metadata.test).to.be('valueStays');
});
it('throws on opening an non-index file', function() {
const indexFile = dataDirectory + '/.index';
fs.writeFileSync(indexFile, 'foo');
expect(() => index = new Index(indexFile)).to.throwError(/Invalid file header/);
});
it('throws on opening an index file with different version', function() {
const indexFile = dataDirectory + '/.index';
fs.writeFileSync(indexFile, 'nesidx00');
expect(() => index = new Index(indexFile)).to.throwError(/Invalid file version/);
});
it('throws on opening an index file with wrong metadata size', function() {
const indexFile = dataDirectory + '/.index';
const metadataBuffer = Buffer.allocUnsafe(8 + 4);
metadataBuffer.write("nesidx01", 0, 8, 'utf8');
metadataBuffer.writeUInt32BE(0, 8);
fs.writeFileSync(indexFile, metadataBuffer);
expect(() => index = new Index(indexFile)).to.throwError(/Invalid metadata size/);
});
it('throws on opening an index file with too large metadata size', function() {
const indexFile = dataDirectory + '/.index';
const metadataBuffer = Buffer.allocUnsafe(8 + 4 + 3);
metadataBuffer.write("nesidx01", 0, 8, 'utf8');
metadataBuffer.writeUInt32BE(255, 8);
metadataBuffer.write("{}\n", 12, 3, 'utf8');
fs.writeFileSync(indexFile, metadataBuffer);
expect(() => index = new Index(indexFile)).to.throwError(/Invalid index file/);
});
it('throws on opening an index file with invalid metadata', function() {
const indexFile = dataDirectory + '/.index';
const metadataBuffer = Buffer.allocUnsafe(8 + 4 + 3);
metadataBuffer.write("nesidx01", 0, 8, 'utf8');
metadataBuffer.writeUInt32BE(255, 8);
metadataBuffer.write("{x$", 12, 3, 'utf8');
fs.writeFileSync(indexFile, metadataBuffer);
expect(() => index = new Index(indexFile)).to.throwError(/Invalid metadata/);
});
it('throws on reopening with altered metadata', function() {
index = createIndex('.index', { metadata: { test: 'valueStays' } });
expect(() => index = createIndex(index.name, { metadata: { test: 'anotherValue' } })).to.throwError(/Index metadata mismatch/);
});
it('truncates on opening with altered file', function() {
index = setupIndexWithEntries(5);
index.close();
fs.appendFileSync(index.fileName, 'foo');
expect(() => index = createIndex(index.name)).to.not.throwError();
expect(index.length).to.be(5);
});
describe('Entry', function() {
it('stores data correctly', function() {
let entry = new Index.Entry(1, 2, 3, 4);
expect(entry.number).to.be(1);
expect(entry.position).to.be(2);
expect(entry.size).to.be(3);
expect(entry.partition).to.be(4);
});
it('correctly validates custom entry classes', function() {
class CustomEntryClassWithMissingFromBuffer {
static get size() { return 4; }
}
class CustomEntryClassWithMissingToBuffer {
static get size() { return 4; }
static fromBuffer(buffer, offset = 0) {}
}
class CustomZeroSizeEntryClass {
static get size() { return 0; }
static fromBuffer(buffer, offset = 0) {}
toBuffer(buffer, offset) {}
}
function CustomEs5Entry() {}
CustomEs5Entry.size = 4;
CustomEs5Entry.fromBuffer = function(buffer, offset) {};
CustomEs5Entry.prototype.toBuffer = function(buffer, offset) {};
expect(() => Index.Entry.assertValidEntryClass({})).to.throwError(/Invalid index entry class/);
expect(() => Index.Entry.assertValidEntryClass(CustomEntryClassWithMissingFromBuffer)).to.throwError(/Invalid index entry class/);
expect(() => Index.Entry.assertValidEntryClass(CustomEntryClassWithMissingToBuffer)).to.throwError(/Invalid index entry class/);
expect(() => Index.Entry.assertValidEntryClass(CustomZeroSizeEntryClass)).to.throwError(/size must be positive/);
expect(() => Index.Entry.assertValidEntryClass(CustomEs5Entry)).to.not.throwError();
});
});
describe('add', function() {
it('appends entries sequentially', function() {
index = setupIndexWithEntries(25);
index.close();
index.open();
let entries = index.all();
expect(entries.length).to.be(25);
for (let i = 1; i <= entries.length; i++) {
expect(entries[i - 1].number).to.be(i);
}
});
it('appends entries to reopened index correctly', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
index.add(new Index.Entry(6, 6));
let entries = index.all();
expect(entries.length).to.be(6);
for (let i = 1; i <= entries.length; i++) {
expect(entries[i - 1].number).to.be(i);
}
});
it('calls callback eventually', function(done) {
index = createIndex('.index', { flushDelay: 1 });
let position = index.add(new Index.Entry(1, 0), (number) => {
expect(number).to.be(position);
done();
});
});
it('flushes automatically when writeBuffer full', function() {
index = setupIndexWithEntries(5, { writeBufferSize: 5 * Index.Entry.size });
expect(index.flush()).to.be(false);
});
it('throws with invalid entry object', function() {
index = createIndex();
expect(() => index.add([1,2,3,4])).to.throwError(/Wrong entry object/);
});
it('throws with invalid entry size', function() {
index = createIndex();
class Entry extends Index.Entry {
static get size() {
return 20;
}
}
expect(() => index.add(new Entry(1, 0))).to.throwError(/Invalid entry size/);
});
});
describe('get', function() {
it('returns false on out of bounds position', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
expect(index.get(0)).to.be(false);
expect(index.get(index.length+1)).to.be(false);
});
it('returns false on closed index', function() {
index = setupIndexWithEntries(5);
index.close();
expect(index.get(1)).to.be(false);
});
it('can read entry from the end', function() {
setupIndexWithEntries(5);
index.close();
index.open();
let entry = index.get(-1);
expect(entry.number).to.be(index.length);
});
it('can random read entries', function() {
index = setupIndexWithEntries(10);
index.close();
index.open();
let entry = index.get(5);
expect(entry.number).to.be(5);
});
it('can read entries multiple times', function() {
index = setupIndexWithEntries(10);
index.close();
index.open();
for (let i = 0; i < 5; i++) {
let entry = index.get(5);
expect(entry.number).to.be(5);
}
});
});
describe('range', function() {
it('returns false on out of bounds range position', function() {
index = setupIndexWithEntries(50);
index.close();
index.open();
expect(index.range(0)).to.be(false);
expect(index.range(51, 55)).to.be(false);
expect(index.range(1, 51)).to.be(false);
expect(index.range(15, 10)).to.be(false);
});
it('returns false on closed index', function() {
index = setupIndexWithEntries(5);
index.close();
expect(index.range(1)).to.be(false);
expect(index.range(1,5)).to.be(false);
});
it('can read an arbitrary range of entries', function() {
index = setupIndexWithEntries(50);
index.close();
index.open();
let entries = index.range(21, 37);
for (let i = 0; i < entries.length; i++) {
expect(entries[i].number).to.be(21 + i);
}
});
it('can read a range of entries from the end', function() {
index = setupIndexWithEntries(50);
index.close();
index.open();
let entries = index.range(-15);
expect(entries.length).to.be(15);
for (let i = 0; i < entries.length; i++) {
expect(entries[i].number).to.be(36 + i);
}
});
it('can read a range of entries until a distance from the end', function() {
index = setupIndexWithEntries(50);
index.close();
index.open();
let entries = index.range(1, -15);
expect(entries.length).to.be(36); // 36 because end is inclusive
for (let i = 0; i < entries.length; i++) {
expect(entries[i].number).to.be(1 + i);
}
});
it('can read a single item range of entries', function() {
index = setupIndexWithEntries(50);
index.close();
index.open();
let entries = index.range(21, 21);
expect(entries.length).to.be(1);
expect(entries[0].number).to.be(21);
});
it('returns false with a non-numeric range', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
let entries = index.range('foo');
expect(entries).to.be(false);
});
});
describe('lastEntry', function() {
it('returns the last entry', function() {
index = setupIndexWithEntries(5);
expect(index.lastEntry.number).to.be(5);
});
it('returns false on empty index', function() {
index = setupIndexWithEntries(0);
expect(index.lastEntry).to.be(false);
});
});
describe('find', function() {
it('returns 0 if no entry is lower or equal searched number', function() {
index = setupIndexWithEntries(5, i => 5 + i);
expect(index.find(index.length)).to.be(0);
});
it('returns last entry if all entries are lower searched number', function() {
index = setupIndexWithEntries(5);
expect(index.find(index.length+1)).to.be(index.length);
});
it('returns 0 if all entries are lower searched number with min=true', function() {
index = setupIndexWithEntries(5);
expect(index.find(index.length+1, true)).to.be(0);
});
it('returns the entry number on exact match', function() {
index = setupIndexWithEntries(5);
for (let i = 1; i <= 5; i++) {
expect(index.find(i)).to.be(i);
}
});
it('returns the highest entry number lower than the searched number', function() {
index = setupIndexWithEntries(50, i => 2*i);
expect(index.find(25)).to.be(12);
});
it('returns the lowest entry number higher than the searched number with min=true', function() {
index = setupIndexWithEntries(50, i => 2*i);
expect(index.find(25, true)).to.be(13);
});
});
describe('truncate', function() {
it('truncates after the given index position', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
index.truncate(2);
expect(index.length).to.be(2);
index.close();
index.open();
expect(index.length).to.be(2);
});
it('correctly truncates after unflushed entries', function() {
index = setupIndexWithEntries(5);
index.truncate(2);
expect(index.length).to.be(2);
index.close();
index.open();
expect(index.length).to.be(2);
});
it('does not truncate closed index', function() {
index = setupIndexWithEntries(5);
index.close();
index.truncate(2);
index.open();
expect(index.length).to.be(5);
});
it('does nothing if truncating after index length', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
index.truncate(6);
expect(index.length).to.be(5);
index.close();
index.open();
expect(index.length).to.be(5);
});
it('truncates whole index if given negative position', function() {
index = setupIndexWithEntries(5);
index.close();
index.open();
index.truncate(-5);
expect(index.length).to.be(0);
index.close();
index.open();
expect(index.length).to.be(0);
});
});
describe('validRange', function(){
it('returns false for out of range from positions', function(){
index = setupIndexWithEntries(5);
expect(index.validRange(0, 1)).to.be(false);
expect(index.validRange(-1, 1)).to.be(false);
expect(index.validRange(index.length + 1, index.length + 2)).to.be(false);
});
it('returns false when from greater until', function(){
index = setupIndexWithEntries(5);
expect(index.validRange(2, 1)).to.be(false);
expect(index.validRange(1, 0)).to.be(false);
});
it('returns false for out of range until positions', function(){
index = setupIndexWithEntries(5);
expect(index.validRange(1, -1)).to.be(false);
expect(index.validRange(1, index.length +1)).to.be(false);
});
it('returns true for valid range positions', function(){
index = setupIndexWithEntries(5);
expect(index.validRange(1, 1)).to.be(true);
expect(index.validRange(1, index.length)).to.be(true);
expect(index.validRange(index.length, index.length)).to.be(true);
});
});
describe('destroy', function(){
it('completely deletes the file', function(){
index = setupIndexWithEntries(5);
const fileName = index.fileName;
index.destroy();
expect(fs.existsSync(fileName)).to.be(false);
});
});
describe('flush', function(){
it('returns false on a closed index', function(){
index = setupIndexWithEntries(1);
index.close();
expect(index.flush()).to.be(false);
});
it('returns false if nothing to flush', function(){
index = setupIndexWithEntries(1);
index.flush();
expect(index.flush()).to.be(false);
});
});
describe('ReadOnly', function(){
it('can be created without explicit name', function(){
expect(() => {
index = createIndex('.index');
let reader = new Index.ReadOnly({ dataDirectory });
reader.close();
}).to.not.throwError();
});
it('can be opened and closed multiple times', function(){
index = createIndex('.index');
let reader = new Index.ReadOnly({ dataDirectory });
expect(reader.open()).to.be(false);
reader.close();
reader.close();
});
it('throws when opening an empty file', function(){
index = createIndex('.index');
index.close();
fs.truncateSync(index.fileName, 0);
expect(() => createReader(index.name)).to.throwError(/empty/);
});
it('allows multiple readers for a single index', function(){
index = setupIndexWithEntries(5);
let reader1 = createReader(index.name);
expect(reader1.isOpen()).to.be(true);
expect(reader1.length).to.be(index.length);
expect(reader1.lastEntry.number).to.be(index.lastEntry.number);
let reader2 = createReader(index.name);
expect(reader2.isOpen()).to.be(true);
expect(reader2.length).to.be(index.length);
expect(reader2.lastEntry.number).to.be(index.lastEntry.number);
});
it('updates when writer flushes', function(done){
index = setupIndexWithEntries(5);
let reader1 = createReader(index.name);
reader1.on('append', (prev, next) => {
expect(prev).to.be(5);
expect(next).to.be(6);
expect(reader1.get(next).number).to.be(index.get(next).number);
done();
});
index.add(new Index.Entry(6, 6));
index.flush();
fs.fdatasync(index.fd);
});
it('updates when writer truncates', function(done){
index = setupIndexWithEntries(5);
let reader1 = createReader(index.name);
reader1.on('truncate', (prev, next) => {
expect(prev).to.be(5);
expect(next).to.be(0);
expect(reader1.length).to.be(0);
done();
});
index.truncate(0);
fs.fdatasync(index.fd);
});
it('closes when file renamed', function(done){
index = setupIndexWithEntries(5);
index.close();
let reader = createReader(index.name);
expect(reader.isOpen()).to.be(true);
fs.rename(reader.fileName, reader.fileName + '2', () => {
setTimeout(() => {
expect(reader.isOpen()).to.be(false);
done();
}, 1);
});
});
it('does not trigger handler when index closed', function(done){
index = setupIndexWithEntries(5);
let reader = createReader(index.name);
reader.on('truncate', () => expect(this).to.be(false));
index.truncate(0);
reader.close();
fs.fdatasync(index.fd, () => done());
});
});
});