tinybase
Version:
A reactive data store and sync engine.
301 lines (291 loc) • 9.25 kB
JavaScript
const getTypeOf = (thing) => typeof thing;
const EMPTY_STRING = '';
const STRING = getTypeOf(EMPTY_STRING);
const T = 't';
const V = 'v';
const strStartsWith = (str, prefix) => str.startsWith(prefix);
const promise = Promise;
const isInstanceOf = (thing, cls) => thing instanceof cls;
const isUndefined = (thing) => thing == void 0;
const ifNotUndefined = (value, then, otherwise) =>
isUndefined(value) ? otherwise?.() : then(value);
const isString = (thing) => getTypeOf(thing) == STRING;
const slice = (arrayOrString, start, end) => arrayOrString.slice(start, end);
const size = (arrayOrString) => arrayOrString.length;
const promiseAll = async (promises) => promise.all(promises);
const arrayEvery = (array, cb) => array.every(cb);
const arrayMap = (array, cb) => array.map(cb);
const arrayIsEmpty = (array) => size(array) == 0;
const arrayPush = (array, ...values) => array.push(...values);
const arrayUnshift = (array, ...values) => array.unshift(...values);
const object = Object;
const objEntries = object.entries;
const objNew = (entries = []) => object.fromEntries(entries);
const objHas = (obj, id) => id in obj;
const objToArray = (obj, cb) =>
arrayMap(objEntries(obj), ([id, value]) => cb(value, id));
const objEnsure = (obj, id, getDefaultValue) => {
if (!objHas(obj, id)) {
obj[id] = getDefaultValue();
}
return obj[id];
};
const jsonString = JSON.stringify;
const jsonParse = JSON.parse;
const jsonStringWithMap = (obj) =>
jsonString(obj, (_key, value) =>
isInstanceOf(value, Map) ? object.fromEntries([...value]) : value,
);
const collForEach = (coll, cb) => coll?.forEach(cb);
const mapForEach = (map, cb) =>
collForEach(map, (value, key) => cb(key, value));
const SET_CHANGES = 's';
const STORE_PATH = '/store';
const PUT = 'PUT';
const construct = (prefix, type, payload) =>
prefix + type + (isString(payload) ? payload : jsonStringWithMap(payload));
const deconstruct = (prefix, message, stringified) => {
const prefixSize = size(prefix);
return strStartsWith(message, prefix)
? [
message[prefixSize],
(stringified ? jsonParse : String)(slice(message, prefixSize + 1)),
]
: void 0;
};
const HAS_STORE = 'hasStore';
const RESPONSE_HEADERS = objNew(
arrayMap(['Origin', 'Methods', 'Headers'], (suffix) => [
'Access-Control-Allow-' + suffix,
'*',
]),
);
const hasStoreInStorage = async (storage, storagePrefix = EMPTY_STRING) =>
!!(await storage.get(storagePrefix + HAS_STORE));
const loadStoreFromStorage = async (storage, storagePrefix = EMPTY_STRING) => {
const tables = {};
const values = {};
mapForEach(await storage.list(), (key, cellOrValue) =>
ifNotUndefined(deconstruct(storagePrefix, key), ([type, ids]) => {
if (type == T) {
const [tableId, rowId, cellId] = jsonParse('[' + ids + ']');
objEnsure(objEnsure(tables, tableId, objNew), rowId, objNew)[cellId] =
cellOrValue;
} else if (type == V) {
values[ids] = cellOrValue;
}
}),
);
return [tables, values];
};
const broadcastChanges = async (server, changes, without) =>
server.party.broadcast(
construct(
server.config.messagePrefix ?? EMPTY_STRING,
SET_CHANGES,
changes,
),
without,
);
const saveStore = async (that, changes, initialSave, requestOrConnection) => {
const storage = that.party.storage;
const storagePrefix = that.config.storagePrefix ?? EMPTY_STRING;
const keysToSet = {
[storagePrefix + HAS_STORE]: 1,
};
const keysToDel = [];
const keyPrefixesToDel = [];
await promiseAll(
objToArray(changes[0], async (table, tableId) =>
isUndefined(table)
? !initialSave &&
(await that.canDelTable(tableId, requestOrConnection)) &&
arrayUnshift(
keyPrefixesToDel,
constructStorageKey(storagePrefix, T, tableId),
)
: (await that.canSetTable(tableId, initialSave, requestOrConnection)) &&
(await promiseAll(
objToArray(table, async (row, rowId) =>
isUndefined(row)
? !initialSave &&
(await that.canDelRow(tableId, rowId, requestOrConnection)) &&
arrayPush(
keyPrefixesToDel,
constructStorageKey(storagePrefix, T, tableId, rowId),
)
: (await that.canSetRow(
tableId,
rowId,
initialSave,
requestOrConnection,
)) &&
(await promiseAll(
objToArray(row, async (cell, cellId) => {
const ids = [tableId, rowId, cellId];
const key = constructStorageKey(storagePrefix, T, ...ids);
if (isUndefined(cell)) {
if (
!initialSave &&
(await that.canDelCell(...ids, requestOrConnection))
) {
arrayPush(keysToDel, key);
}
} else if (
await that.canSetCell(
...ids,
cell,
initialSave,
requestOrConnection,
await storage.get(key),
)
) {
keysToSet[key] = cell;
}
}),
)),
),
)),
),
);
await promiseAll(
objToArray(changes[1], async (value, valueId) => {
const key = storagePrefix + V + valueId;
if (isUndefined(value)) {
if (
!initialSave &&
(await that.canDelValue(valueId, requestOrConnection))
) {
arrayPush(keysToDel, key);
}
} else if (
await that.canSetValue(
valueId,
value,
initialSave,
requestOrConnection,
await storage.get(key),
)
) {
keysToSet[key] = value;
}
}),
);
if (!arrayIsEmpty(keyPrefixesToDel)) {
mapForEach(await storage.list(), (key) =>
arrayEvery(
keyPrefixesToDel,
(keyPrefixToDelete) =>
!strStartsWith(key, keyPrefixToDelete) ||
(arrayPush(keysToDel, key) && 0),
),
);
}
await storage.delete(keysToDel);
await storage.put(keysToSet);
};
const constructStorageKey = (storagePrefix, type, ...ids) =>
construct(storagePrefix, type, slice(jsonStringWithMap(ids), 1, -1));
const createResponse = async (that, status, body = null) =>
new Response(body, {
status,
headers: that.config.responseHeaders,
});
class TinyBasePartyKitServer {
constructor(party) {
this.party = party;
this.config.storePath ??= STORE_PATH;
this.config.messagePrefix ??= EMPTY_STRING;
this.config.storagePrefix ??= EMPTY_STRING;
this.config.responseHeaders ??= RESPONSE_HEADERS;
}
config = {};
async onRequest(request) {
const {
party: {storage},
config: {storePath = STORE_PATH, storagePrefix},
} = this;
if (new URL(request.url).pathname.endsWith(storePath)) {
const hasExistingStore = await hasStoreInStorage(storage, storagePrefix);
const text = await request.text();
if (request.method == PUT) {
if (hasExistingStore) {
return createResponse(this, 205);
}
await saveStore(this, jsonParse(text), true, request);
return createResponse(this, 201);
}
return createResponse(
this,
200,
hasExistingStore
? jsonStringWithMap(
await loadStoreFromStorage(storage, storagePrefix),
)
: EMPTY_STRING,
);
}
return createResponse(this, 404);
}
async onMessage(message, connection) {
const {
config: {messagePrefix = EMPTY_STRING, storagePrefix},
} = this;
await ifNotUndefined(
deconstruct(messagePrefix, message, 1),
async ([type, payload]) => {
if (
type == SET_CHANGES &&
(await hasStoreInStorage(this.party.storage, storagePrefix))
) {
await saveStore(this, payload, false, connection);
broadcastChanges(this, payload, [connection.id]);
}
},
);
}
async canSetTable(_tableId, _initialSave, _requestOrConnection) {
return true;
}
async canDelTable(_tableId, _connection) {
return true;
}
async canSetRow(_tableId, _rowId, _initialSave, _requestOrConnection) {
return true;
}
async canDelRow(_tableId, _rowId, _connection) {
return true;
}
async canSetCell(
_tableId,
_rowId,
_cellId,
_cell,
_initialSave,
_requestOrConnection,
_oldCell,
) {
return true;
}
async canDelCell(_tableId, _rowId, _cellId, _connection) {
return true;
}
async canSetValue(
_valueId,
_value,
_initialSave,
_requestOrConnection,
_oldValue,
) {
return true;
}
async canDelValue(_valueId, _connection) {
return true;
}
}
export {
TinyBasePartyKitServer,
broadcastChanges,
hasStoreInStorage,
loadStoreFromStorage,
};