@x5e/gink
Version:
an eventually consistent database
429 lines (408 loc) • 16.1 kB
text/typescript
import { Database } from "./Database";
import { Container } from "./Container";
import { AsOf, Entry, Muid, Value, Bundler, Meta } from "./typedefs";
import {
ensure,
generateTimestamp,
muidToBuilder,
muidToString,
muidTupleToMuid,
muidTupleToString,
wrapKey,
wrapValue,
} from "./utils";
import { construct, interpret, toJson } from "./factories";
import { Behavior, ChangeBuilder } from "./builders";
import { EntryBuilder } from "./builders";
import { movementHelper } from "./store_utils";
/**
* Kind of like the Gink version of a Javascript Array; supports push, pop, shift.
* Doesn't support unshift because order is defined by insertion order.
*/
export class Sequence extends Container {
private constructor(database: Database, address?: Muid) {
super(database, address, Behavior.SEQUENCE);
}
static get(database?: Database, muid?: Muid): Sequence {
database = database || Database.recent;
muid = muid ?? {
timestamp: -1,
medallion: -1,
offset: Behavior.SEQUENCE,
};
return new Sequence(database, muid);
}
static async create(database?: Database, meta?: Meta): Promise<Sequence> {
database = database || Database.recent;
const muid = await Container.addContainer({
behavior: Behavior.SEQUENCE,
database,
meta,
});
return new Sequence(database, muid);
}
/**
* Adds an element to the end of the list.
* @param value
* @param change change set to apply the change to or comment to put in
* @returns
*/
async push(value: Value | Container, meta?: Meta): Promise<Muid> {
return await this.addEntry(undefined, value, meta);
}
async move(
what: number | Muid,
dest: number,
purge?: boolean,
meta?: Meta,
) {
let bundler: Bundler = await this.database.startBundle(meta);
const store = this.database.store;
let muid: Muid;
if (typeof what === "number") {
muid = muidTupleToMuid(
Array.from(
(
await store.getOrderedEntries(this.address, what)
).values(),
).pop().entryId,
);
} else {
muid = what;
}
ensure(muid.timestamp && muid.medallion && muid.offset);
await movementHelper(
bundler,
muid,
this.address,
await this.findDest(dest),
purge,
);
if (!meta?.bundler) {
await bundler.commit();
}
}
async reset(toTime?: AsOf, recurse?, meta?: Meta): Promise<void> {
if (recurse === true) recurse = new Set();
if (recurse instanceof Set) {
recurse.add(muidToString(this.address));
}
const bundler: Bundler = await this.database.startBundle(meta);
if (!toTime) {
// If no time is specified, we are resetting to epoch, which is just a clear
this.clear(false, { bundler });
} else {
const entriesThen = await this.database.store.getOrderedEntries(
this.address,
Infinity,
toTime,
);
// Need something subscriptable to compare by position
const entriesNow = await this.database.store.getOrderedEntries(
this.address,
Infinity,
);
for (const [key, entry] of entriesThen) {
const placementTupleThen = entry.placementId;
const placementNow = await this.database.store.getLocation(
muidTupleToMuid(entry.entryId),
);
const placementTupleNow = placementNow
? placementNow.placement
: undefined;
if (!placementNow) {
// This entry existed then, but has since been deleted
// Need to re-add it to the previous location
const entryBuilder = new EntryBuilder();
entryBuilder.setContainer(muidToBuilder(this.address));
entryBuilder.setKey(wrapKey(placementTupleThen[0]));
entryBuilder.setBehavior(entry.behavior);
if (entry.value !== undefined) {
entryBuilder.setValue(wrapValue(entry.value));
}
if (entry.pointeeList.length > 0) {
const pointeeMuid = muidTupleToMuid(
entry.pointeeList[0],
);
entryBuilder.setPointee(muidToBuilder(pointeeMuid));
}
const changeBuilder = new ChangeBuilder();
changeBuilder.setEntry(entryBuilder);
bundler.addChange(changeBuilder);
} else {
if (
placementTupleNow &&
placementTupleThen[0] !== placementTupleNow[0]
) {
// This entry exists, but has been moved
// Need to move it back
await movementHelper(
bundler,
muidTupleToMuid(entry.entryId),
this.address,
placementTupleThen[0],
false,
);
}
// Need to remove the current entry from entriesNow if
// 1) the entry exists but was moved, or 2) the entry is untouched
ensure(
entriesNow.delete(
`${placementTupleNow[0]},${muidTupleToString(entry.entryId)}`,
),
"entry not found in entriesNow",
);
}
// Finally, if the previous entry was a container, recusively reset it
if (recurse && entry.pointeeList.length > 0) {
const pointeeMuid = muidTupleToMuid(entry.pointeeList[0]);
if (!recurse.has(muidToString(pointeeMuid))) {
const container = await construct(
pointeeMuid,
this.database,
);
await container.reset(toTime, recurse, { bundler });
}
}
}
// We will need to loop through the remaining entries in entriesNow
// to delete them, since we know they weren't in the sequence at toTime
for (const [key, entry] of entriesNow) {
await movementHelper(
bundler,
muidTupleToMuid(entry.entryId),
this.address,
undefined,
false,
);
}
}
if (!meta?.bundler) {
await bundler.commit();
}
}
private async findDest(dest: number): Promise<number> {
if (dest === 0 || dest === -1) {
const currentFrontOrBack = <number>(
(await this.getEntryAt(dest)).storageKey
);
return (
currentFrontOrBack -
Math.sign(dest + 0.5) * Math.floor(1e3 * Math.random())
);
}
if (dest > +1e6) return dest;
if (dest < -1e6) return generateTimestamp() + dest;
const entryMap = await this.database.store.getOrderedEntries(
this.address,
dest,
);
const entryArray = Array.from(entryMap.entries());
const a = entryArray[entryArray.length - 2];
const b = entryArray[entryArray.length - 1];
const aTs = Number.parseInt(a[0].split(",")[0]);
const bTs = Number.parseInt(b[0].split(",")[0]);
if (Math.abs(aTs - bTs) < 2)
throw new Error("can't find space between entries");
return Math.floor((aTs + bTs) / 2);
}
async pop(
what?: Muid | number,
purge?: boolean,
meta?: Meta,
): Promise<Container | Value | undefined> {
let bundler: Bundler = await this.database.startBundle(meta);
let returning: Container | Value;
let muid: Muid;
if (what && typeof what != "number" && what.offset) {
muid = what;
const entry = await this.database.store.getEntryById(muid);
if (!entry) return undefined;
ensure(
entry.entryId[0] === muid.timestamp &&
entry.entryId[2] === muid.offset,
);
returning = await interpret(entry, this.database);
} else {
const position = what === undefined ? -1 : <number>what;
// Should probably change the implementation to not copy all intermediate entries into memory.
const entries = Array.from(
(
await this.database.store.getOrderedEntries(
this.address,
position,
)
).values(),
);
if (entries.length === 0) return undefined;
const entry = entries[entries.length - 1];
returning = await interpret(entry, this.database);
muid = muidTupleToMuid(entry.entryId);
}
await movementHelper(bundler, muid, this.address, undefined, purge);
if (!meta?.bundler) {
await bundler.commit();
}
return returning;
}
/**
* Alias for this.pop with position of 0
*/
async shift(
purge?: boolean,
meta?: Meta,
): Promise<Container | Value | undefined> {
return await this.pop(0, purge, meta);
}
/**
* Adds multiple entries into this sequence.
* NOTE: If you pass a bundler, all changes will share the same timestamp. This means you will
* not be able to move new entries in between these (you may move these entries between one another).
* Without a bundler, each item from the iterable will be committed separately, which will be costly,
* but there won't be the same restrictions on moving.
* @param iterable An iterable of stuff to add to the sequence.
* @param meta optional place to pass in a comment or bundler
*/
async extend(
iterable: Iterable<Value | Container>,
meta?: Meta,
): Promise<void> {
const bundler = await this.database.startBundle(meta);
for (const value of iterable) {
await this.push(value, { bundler });
}
if (!meta?.bundler) await bundler.commit();
}
private async getEntryAt(
position: number,
asOf?: AsOf,
): Promise<Entry | undefined> {
//TODO add a store method to only return the entry at a given location
const entries = await this.database.store.getOrderedEntries(
this.address,
position,
asOf,
);
if (entries.size === 0) return undefined;
if (position >= 0 && position >= entries.size) return undefined;
if (position < 0 && Math.abs(position) > entries.size) return undefined;
let val: Entry;
for (let found of entries.values()) {
val = found;
}
return val;
}
/**
*
* @param position Index to look for the thing, negative counts from end, or muid of entry
* @param asOf
* @returns value at the position of the list, or undefined if list is too small
*/
async at(
position: number,
asOf?: AsOf,
): Promise<Container | Value | undefined> {
if (typeof position === "number") {
const entry = await this.getEntryAt(position, asOf);
return await interpret(entry, this.database);
}
throw Error("unexpected");
}
/**
* Dumps the contents of this list to a javascript array.
* useful for debugging and could also be used to export data by walking the tree
* @param through how many elements to get, positive starting from beginning, negative starting from end
* @param asOf effective time to get the dump for: leave undefined to get data as of the present
* @returns an array containing Values (e.g. numbers, strings) and Containers (e.g. other Lists, Boxes, Directories)
*/
async toArray(
through = Infinity,
asOf?: AsOf,
): Promise<(Container | Value)[]> {
const thisList = this;
const entries = await thisList.database.store.getOrderedEntries(
thisList.address,
through,
asOf,
);
const applied = Array.from(entries.values());
return await Promise.all(
applied.map(async function (
entry: Entry,
): Promise<Container | Value> {
return await interpret(entry, thisList.database);
}),
);
}
async size(asOf?: AsOf): Promise<number> {
const entries = await this.database.store.getOrderedEntries(
this.address,
Infinity,
asOf,
);
return entries.size;
}
/**
* Function to iterate over the contents of the List, showing the address of each entry (which can be used in pop).
* @param through count of many things to iterate through, positive starting from front, negative for end
* @param asOf effective time to get the contents for
* @returns an async iterator across everything in the list, with values returned being pairs of Muid, (Value|Container),
*/
entries(
through = Infinity,
asOf?: AsOf,
): AsyncGenerator<[Muid, Value | Container], void, unknown> {
const thisList = this;
return (async function* () {
// Note: I'm loading all entries memory despite using an async generator due to shitty IndexedDb
// behavior of closing transactions when you await on something else. Hopefully they'll fix that in
// the future and I can improve this. Alternative, it might make sense to hydrate everything in a single pass.
const entries = await thisList.database.store.getOrderedEntries(
thisList.address,
through,
asOf,
);
for (const entry of entries) {
const hydrated = await interpret(entry[1], thisList.database);
yield [muidTupleToMuid(entry[1].entryId), hydrated];
}
})();
}
/**
* Generates a JSON representation of the data in the list.
* Mostly intended for demo/debug purposes.
* @param indent true to pretty print
* @param asOf effective time
* @param seen (internal use only! This prevents cycles from breaking things)
* @returns a JSON string
*/
async toJson(
indent: number | boolean = false,
asOf?: AsOf,
seen?: Set<string>,
): Promise<string> {
if (seen === undefined) seen = new Set();
ensure(indent === false, "indent not implemented");
const mySig = muidToString(this.address);
if (seen.has(mySig)) return "null";
seen.add(mySig);
const asArray = await this.toArray(Infinity, asOf);
let returning = "[";
let first = true;
for (const value of asArray) {
if (first) {
first = false;
} else {
returning += ",";
}
returning += await toJson(
value,
indent === false ? false : +indent + 1,
asOf,
seen,
);
}
returning += "]";
return returning;
}
}