@instantdb/core
Version:
Instant's core local abstraction
677 lines (597 loc) • 17.4 kB
JavaScript
import { create } from 'mutative';
import { immutableDeepMerge } from './utils/object.js';
function hasEA(attr) {
return attr['cardinality'] === 'one';
}
function isRef(attr) {
return attr['value-type'] === 'ref';
}
export function isBlob(attr) {
return attr['value-type'] === 'blob';
}
function getAttr(attrs, attrId) {
return attrs[attrId];
}
function getInMap(obj, path) {
return path.reduce((acc, key) => acc && acc.get(key), obj);
}
function deleteInMap(m, path) {
if (path.length === 0) throw new Error('path must have at least one element');
if (path.length === 1) {
m.delete(path[0]);
return;
}
const [head, ...tail] = path;
if (!m.has(head)) return;
deleteInMap(m.get(head), tail);
}
function setInMap(m, path, value) {
if (path.length === 0) throw new Error('path must have at least one element');
if (path.length === 1) {
m.set(path[0], value);
return;
}
const [head, ...tail] = path;
let nextM = m.get(head);
if (!nextM) {
nextM = new Map();
m.set(head, nextM);
}
setInMap(nextM, tail, value);
}
function createTripleIndexes(attrs, triples) {
const eav = new Map();
const aev = new Map();
const vae = new Map();
for (const triple of triples) {
const [eid, aid, v, t] = triple;
const attr = getAttr(attrs, aid);
if (!attr) {
console.warn('no such attr', eid, attrs);
continue;
}
if (isRef(attr)) {
setInMap(vae, [v, aid, eid], triple);
}
setInMap(eav, [eid, aid, v], triple);
setInMap(aev, [aid, eid, v], triple);
}
return { eav, aev, vae };
}
function createAttrIndexes(attrs) {
const blobAttrs = new Map();
const primaryKeys = new Map();
const forwardIdents = new Map();
const revIdents = new Map();
for (const attr of Object.values(attrs)) {
const fwdIdent = attr['forward-identity'];
const [_, fwdEtype, fwdLabel] = fwdIdent;
const revIdent = attr['reverse-identity'];
setInMap(forwardIdents, [fwdEtype, fwdLabel], attr);
if (isBlob(attr)) {
setInMap(blobAttrs, [fwdEtype, fwdLabel], attr);
}
if (attr['primary?']) {
setInMap(primaryKeys, [fwdEtype], attr);
}
if (revIdent) {
const [_, revEtype, revLabel] = revIdent;
setInMap(revIdents, [revEtype, revLabel], attr);
}
}
return { blobAttrs, primaryKeys, forwardIdents, revIdents };
}
export function toJSON(store) {
return {
__type: store.__type,
attrs: store.attrs,
triples: allMapValues(store.eav, 3),
cardinalityInference: store.cardinalityInference,
linkIndex: store.linkIndex,
};
}
export function fromJSON(storeJSON) {
return createStore(
storeJSON.attrs,
storeJSON.triples,
storeJSON.cardinalityInference,
storeJSON.linkIndex,
);
}
function resetAttrIndexes(store) {
store.attrIndexes = createAttrIndexes(store.attrs);
}
export function createStore(
attrs,
triples,
enableCardinalityInference,
linkIndex,
) {
const store = createTripleIndexes(attrs, triples);
store.attrs = attrs;
store.attrIndexes = createAttrIndexes(attrs);
store.cardinalityInference = enableCardinalityInference;
store.linkIndex = linkIndex;
store.__type = 'store';
return store;
}
// We may have local triples with lookup refs in them,
// we need to convert those lookup refs to eids to insert them
// into the store. If we can't find the lookup ref locally,
// then we drop the triple and have to wait for the server response
// to see the optimistic updates.
function resolveLookupRefs(store, triple) {
let eid;
// Check if `e` is a lookup ref
if (Array.isArray(triple[0])) {
const [a, v] = triple[0];
const eMaps = store.aev.get(a);
if (!eMaps) {
// We don't have the attr, so don't try to add the
// triple to the store
return null;
}
// This would be a lot more efficient with a ave index
const triples = allMapValues(eMaps, 2);
eid = triples.find((x) => x[2] === v)?.[0];
} else {
eid = triple[0];
}
if (!eid) {
// We don't know the eid that the ref refers to, so
// we can't add the triple to the store.
return null;
}
// Check if v is a lookup ref
const lookupV = triple[2];
if (
Array.isArray(lookupV) &&
lookupV.length === 2 &&
store.aev.get(lookupV[0])
) {
const [a, v] = lookupV;
const eMaps = store.aev.get(a);
if (!eMaps) {
// We don't have the attr, so don't try to add the
// triple to the store
return null;
}
const triples = allMapValues(eMaps, 2);
const value = triples.find((x) => x[2] === v)?.[0];
if (!value) {
return null;
}
const [_e, aid, _v, ...rest] = triple;
return [eid, aid, value, ...rest];
} else {
const [_, ...rest] = triple;
return [eid, ...rest];
}
}
function retractTriple(store, rawTriple) {
const triple = resolveLookupRefs(store, rawTriple);
if (!triple) {
return;
}
const [eid, aid, v] = triple;
const attr = getAttr(store.attrs, aid);
if (!attr) {
return;
}
deleteInMap(store.eav, [eid, aid, v]);
deleteInMap(store.aev, [aid, eid, v]);
if (isRef(attr)) {
deleteInMap(store.vae, [v, aid, eid]);
}
}
let _seed = 0;
function getCreatedAt(store, attr, triple) {
const [eid, aid, v] = triple;
let createdAt;
const t = getInMap(store.ea, [eid, aid, v]);
if (t) {
createdAt = t[3];
}
/**
* (XXX)
* Two hacks here, for generating a `createdAt`
*
* 1. We multiply Date.now() by 10, to make sure that
* `createdAt` is always greater than anything the server
* could return
*
* We do this because right now we know we _only_ insert
* triples as optimistic updates.
*
* 2. We increment by `_seed`, to make sure there are no
* two triples with the same `createdAt`. This is
* done to make tests more predictable.
*
* We may need to rethink this. Because we * 10, we can't
* use this value as an _actual_ `createdAt` timestamp.
* Eventually we may want too though; For example, we could
* use `createdAt` for each triple, to infer a `createdAt` and
* `updatedAt` value for each object.
*/
return createdAt || Date.now() * 10 + _seed++;
}
function addTriple(store, rawTriple) {
const triple = resolveLookupRefs(store, rawTriple);
if (!triple) {
return;
}
const [eid, aid, v] = triple;
const attr = getAttr(store.attrs, aid);
if (!attr) {
// (XXX): Due to the way we're handling attrs, it's
// possible to enter a state where we receive a triple without an attr.
// See: https://github.com/jsventures/instant-local/pull/132 for details.
// For now, if we receive a command without an attr, we no-op.
return;
}
const existingTriple = getInMap(store.eav, [eid, aid, v]);
// Reuse the created_at for a triple if it's already in the store.
// Prevents updates from temporarily pushing an entity to the top
// while waiting for the server response.
const t = existingTriple?.[3] ?? getCreatedAt(store, attr, triple);
const enhancedTriple = [eid, aid, v, t];
if (hasEA(attr)) {
setInMap(store.eav, [eid, aid], new Map([[v, enhancedTriple]]));
setInMap(store.aev, [aid, eid], new Map([[v, enhancedTriple]]));
} else {
setInMap(store.eav, [eid, aid, v], enhancedTriple);
setInMap(store.aev, [aid, eid, v], enhancedTriple);
}
if (isRef(attr)) {
setInMap(store.vae, [v, aid, eid], enhancedTriple);
}
}
function mergeTriple(store, rawTriple) {
const triple = resolveLookupRefs(store, rawTriple);
if (!triple) {
return;
}
const [eid, aid, update] = triple;
const attr = getAttr(store.attrs, aid);
if (!attr) return;
if (!isBlob(attr))
throw new Error('merge operation is not supported for links');
const eavValuesMap = getInMap(store.eav, [eid, aid]);
if (!eavValuesMap) return;
const currentTriple = eavValuesMap.values().next()?.value;
if (!currentTriple) return;
const currentValue = currentTriple[2];
const updatedValue = immutableDeepMerge(currentValue, update);
const enhancedTriple = [
eid,
aid,
updatedValue,
getCreatedAt(store, attr, currentTriple),
];
setInMap(store.eav, [eid, aid], new Map([[updatedValue, enhancedTriple]]));
}
function deleteEntity(store, args) {
const [lookup, etype] = args;
const triple = resolveLookupRefs(store, [lookup]);
if (!triple) {
return;
}
const [id] = triple;
// delete forward links and attributes + cardinality one links
const eMap = store.eav.get(id);
if (eMap) {
for (const a of eMap.keys()) {
const attr = store.attrs[a];
// delete cascade refs
if (attr && attr['on-delete-reverse'] === 'cascade') {
allMapValues(eMap.get(a), 1).forEach(([e, a, v]) =>
deleteEntity(store, [v, attr['reverse-identity']?.[1]]),
);
}
if (
// Fall back to deleting everything if we've rehydrated tx-steps from
// the store that didn't set `etype` in deleteEntity
!etype ||
// If we don't know about the attr, let's just get rid of it
!attr ||
// Make sure it matches the etype
attr['forward-identity']?.[1] === etype
) {
deleteInMap(store.aev, [a, id]);
deleteInMap(store.eav, [id, a]);
}
}
// Clear out the eav index for `id` if we deleted all of the attributes
if (eMap.size === 0) {
deleteInMap(store.eav, [id]);
}
}
// delete reverse links
const vaeTriples = store.vae.get(id) && allMapValues(store.vae.get(id), 2);
if (vaeTriples) {
vaeTriples.forEach((triple) => {
const [e, a, v] = triple;
const attr = store.attrs[a];
if (!etype || !attr || attr['reverse-identity']?.[1] === etype) {
deleteInMap(store.eav, [e, a, v]);
deleteInMap(store.aev, [a, e, v]);
deleteInMap(store.vae, [v, a, e]);
}
if (attr && attr['on-delete'] === 'cascade') {
deleteEntity(store, [e, attr['forward-identity']?.[1]]);
}
});
}
// Clear out vae index for `id` if we deleted all the reverse attributes
if (store.vae.get(id)?.size === 0) {
deleteInMap(store.vae, [id]);
}
}
// (XXX): Whenever we change/delete attrs,
// We indiscriminately reset the index map.
// There are lots of opportunities for optimization:
// * We _only_ need to run this indexes change. We could detect that
// * We could batch this reset at the end
// * We could add an ave index for all triples, so removing the
// right triples is easy and fast.
function resetIndexMap(store, newTriples) {
const newIndexMap = createTripleIndexes(store.attrs, newTriples);
Object.keys(newIndexMap).forEach((key) => {
store[key] = newIndexMap[key];
});
}
function addAttr(store, [attr]) {
store.attrs[attr.id] = attr;
resetAttrIndexes(store);
}
function getAllTriples(store) {
return allMapValues(store.eav, 3);
}
function deleteAttr(store, [id]) {
if (!store.attrs[id]) return;
const newTriples = getAllTriples(store).filter(([_, aid]) => aid !== id);
delete store.attrs[id];
resetAttrIndexes(store);
resetIndexMap(store, newTriples);
}
function updateAttr(store, [partialAttr]) {
const attr = store.attrs[partialAttr.id];
if (!attr) return;
store.attrs[partialAttr.id] = { ...attr, ...partialAttr };
resetAttrIndexes(store);
resetIndexMap(store, getAllTriples(store));
}
function applyTxStep(store, txStep) {
const [action, ...args] = txStep;
switch (action) {
case 'add-triple':
addTriple(store, args);
break;
case 'deep-merge-triple':
mergeTriple(store, args);
break;
case 'retract-triple':
retractTriple(store, args);
break;
case 'delete-entity':
deleteEntity(store, args);
break;
case 'add-attr':
addAttr(store, args);
break;
case 'delete-attr':
deleteAttr(store, args);
break;
case 'update-attr':
updateAttr(store, args);
break;
case 'rule-params':
break;
default:
throw new Error(`unhandled transaction action: ${action}`);
}
}
export function allMapValues(m, level, res = []) {
if (!m) {
return res;
}
if (level === 0) {
return res;
}
if (level === 1) {
for (const v of m.values()) {
res.push(v);
}
return res;
}
for (const v of m.values()) {
allMapValues(v, level - 1, res);
}
return res;
}
function triplesByValue(store, m, v) {
const res = [];
if (v?.hasOwnProperty('$not')) {
for (const candidate of m.keys()) {
if (v.$not !== candidate) {
res.push(m.get(candidate));
}
}
return res;
}
if (v?.hasOwnProperty('$isNull')) {
const { attrId, isNull, reverse } = v.$isNull;
if (reverse) {
for (const candidate of m.keys()) {
const vMap = store.vae.get(candidate);
const isValNull =
!vMap || vMap.get(attrId)?.get(null) || !vMap.get(attrId);
if (isNull ? isValNull : !isValNull) {
res.push(m.get(candidate));
}
}
} else {
const aMap = store.aev.get(attrId);
for (const candidate of m.keys()) {
const isValNull =
!aMap || aMap.get(candidate)?.get(null) || !aMap.get(candidate);
if (isNull ? isValNull : !isValNull) {
res.push(m.get(candidate));
}
}
}
return res;
}
if (v?.$comparator) {
// TODO: A sorted index would be nice here
return allMapValues(m, 1).filter(v.$op);
}
const values = v.in || v.$in || [v];
for (const value of values) {
const triple = m.get(value);
if (triple) {
res.push(triple);
}
}
return res;
}
// A poor man's pattern matching
// Returns either eav, ea, ev, av, v, or ''
function whichIdx(e, a, v) {
let res = '';
if (e !== undefined) {
res += 'e';
}
if (a !== undefined) {
res += 'a';
}
if (v !== undefined) {
res += 'v';
}
return res;
}
export function getTriples(store, [e, a, v]) {
const idx = whichIdx(e, a, v);
switch (idx) {
case 'e': {
const eMap = store.eav.get(e);
return allMapValues(eMap, 2);
}
case 'ea': {
const aMap = store.eav.get(e)?.get(a);
return allMapValues(aMap, 1);
}
case 'eav': {
const aMap = store.eav.get(e)?.get(a);
if (!aMap) {
return [];
}
return triplesByValue(store, aMap, v);
}
case 'ev': {
const eMap = store.eav.get(e);
if (!eMap) {
return [];
}
const res = [];
for (const aMap of eMap.values()) {
res.push(...triplesByValue(store, aMap, v));
}
return res;
}
case 'a': {
const aMap = store.aev.get(a);
return allMapValues(aMap, 2);
}
case 'av': {
const aMap = store.aev.get(a);
if (!aMap) {
return [];
}
const res = [];
for (const eMap of aMap.values()) {
res.push(...triplesByValue(store, eMap, v));
}
return res;
}
case 'v': {
const res = [];
for (const eMap of store.eav.values()) {
for (const aMap of eMap.values()) {
res.push(...triplesByValue(store, aMap, v));
}
}
return res;
}
default: {
return allMapValues(store.eav, 3);
}
}
}
export function getAsObject(store, attrs, e) {
const obj = {};
for (const [label, attr] of attrs.entries()) {
const aMap = store.eav.get(e)?.get(attr.id);
const triples = allMapValues(aMap, 1);
for (const triple of triples) {
obj[label] = triple[2];
}
}
return obj;
}
export function getAttrByFwdIdentName(store, inputEtype, inputLabel) {
return store.attrIndexes.forwardIdents.get(inputEtype)?.get(inputLabel);
}
export function getAttrByReverseIdentName(store, inputEtype, inputLabel) {
return store.attrIndexes.revIdents.get(inputEtype)?.get(inputLabel);
}
export function getBlobAttrs(store, etype) {
return store.attrIndexes.blobAttrs.get(etype);
}
export function getPrimaryKeyAttr(store, etype) {
const fromPrimary = store.attrIndexes.primaryKeys.get(etype);
if (fromPrimary) {
return fromPrimary;
}
return store.attrIndexes.forwardIdents.get(etype)?.get('id');
}
function findTriple(store, rawTriple) {
const triple = resolveLookupRefs(store, rawTriple);
if (!triple) {
return;
}
const [eid, aid, v] = triple;
const attr = getAttr(store.attrs, aid);
if (!attr) {
// (XXX): Due to the way we're handling attrs, it's
// possible to enter a state where we receive a triple without an attr.
// See: https://github.com/jsventures/instant-local/pull/132 for details.
// For now, if we receive a command without an attr, we no-op.
return;
}
return getInMap(store.eav, [eid, aid]);
}
export function transact(store, txSteps) {
const txStepsFiltered = txSteps.filter(([action, ...rawTriple]) => {
if (action !== 'add-triple' && action !== 'deep-merge-triple') {
return true;
}
const mode = rawTriple[3]?.mode;
if (mode !== 'create' && mode !== 'update') {
return true;
}
const exists = findTriple(store, rawTriple);
if (mode === 'create' && exists) {
return false;
}
if (mode === 'update' && !exists) {
return false;
}
return true;
});
return create(store, (draft) => {
txStepsFiltered.forEach((txStep) => {
applyTxStep(draft, txStep);
});
});
}