@instantdb/core
Version:
Instant's core local abstraction
593 lines (545 loc) • 17.7 kB
JavaScript
import { allMapValues } from './store.js';
import { getOps, isLookup, parseLookup } from './instatx.ts';
import { immutableRemoveUndefined } from './utils/object.js';
import uuid from './utils/uuid.ts';
// Rewrites optimistic attrs with the attrs we get back from the server.
export function rewriteStep(attrMapping, txStep) {
const { attrIdMap, refSwapAttrIds } = attrMapping;
const rewritten = [];
for (const part of txStep) {
const newValue = attrIdMap[part];
if (newValue) {
// Rewrites attr id
rewritten.push(newValue);
} else if (Array.isArray(part) && part.length == 2 && attrIdMap[part[0]]) {
// Rewrites attr id in lookups
const [aid, value] = part;
rewritten.push([attrIdMap[aid], value]);
} else {
rewritten.push(part);
}
}
const [action] = txStep;
if (
(action === 'add-triple' || action === 'retract-triple') &&
refSwapAttrIds.has(txStep[2])
) {
// Reverse links if the optimistic link attr is backwards
const tmp = rewritten[1];
rewritten[1] = rewritten[3];
rewritten[3] = tmp;
}
return rewritten;
}
export function getAttrByFwdIdentName(attrs, inputEtype, inputIdentName) {
return Object.values(attrs).find((attr) => {
const [_id, etype, label] = attr['forward-identity'];
return etype === inputEtype && label === inputIdentName;
});
}
export function getAttrByReverseIdentName(attrs, inputEtype, inputIdentName) {
return Object.values(attrs).find((attr) => {
const revIdent = attr['reverse-identity'];
if (!revIdent) return false;
const [_id, etype, label] = revIdent;
return etype === inputEtype && label === inputIdentName;
});
}
function explodeLookupRef(eid) {
if (Array.isArray(eid)) {
return eid;
}
const entries = Object.entries(eid);
if (entries.length !== 1) {
throw new Error(
'lookup must be an object with a single unique attr and value.',
);
}
return entries[0];
}
function isRefLookupIdent(attrs, etype, identName) {
return (
identName.indexOf('.') !== -1 &&
// attr names can have `.` in them, so use the attr we find with a `.`
// before assuming it's a ref lookup.
!getAttrByFwdIdentName(attrs, etype, identName)
);
}
function extractRefLookupFwdName(identName) {
const [fwdName, idIdent, ...rest] = identName.split('.');
if (rest.length > 0 || idIdent !== 'id') {
throw new Error(`${identName} is not a valid lookup attribute.`);
}
return fwdName;
}
function lookupIdentToAttr(attrs, etype, identName) {
if (!isRefLookupIdent(attrs, etype, identName)) {
return getAttrByFwdIdentName(attrs, etype, identName);
}
const fwdName = extractRefLookupFwdName(identName);
const refAttr =
getAttrByFwdIdentName(attrs, etype, fwdName) ||
getAttrByReverseIdentName(attrs, etype, fwdName);
if (refAttr && refAttr['value-type'] !== 'ref') {
throw new Error(`${identName} does not reference a valid link attribute.`);
}
return refAttr;
}
// Returns [attr, value] for the eid if the eid is a lookup.
// If it's a regular eid, returns null
function lookupPairOfEid(eid) {
if (typeof eid === 'string' && !isLookup(eid)) {
return null;
}
return typeof eid === 'string' && isLookup(eid)
? parseLookup(eid)
: explodeLookupRef(eid);
}
function extractLookup(attrs, etype, eid) {
const lookupPair = lookupPairOfEid(eid);
if (lookupPair === null) {
return eid;
}
const [identName, value] = lookupPair;
const attr = lookupIdentToAttr(attrs, etype, identName);
if (!attr || !attr['unique?']) {
throw new Error(`${identName} is not a unique attribute.`);
}
return [attr.id, value];
}
function withIdAttrForLookup(attrs, etype, eidA, txSteps) {
const lookup = extractLookup(attrs, etype, eidA);
if (!Array.isArray(lookup)) {
return txSteps;
}
const idTuple = [
'add-triple',
lookup,
getAttrByFwdIdentName(attrs, etype, 'id').id,
lookup,
];
return [idTuple].concat(txSteps);
}
function expandLink({ attrs }, [etype, eidA, obj]) {
const addTriples = Object.entries(obj).flatMap(([label, eidOrEids]) => {
const eids = Array.isArray(eidOrEids) ? eidOrEids : [eidOrEids];
const fwdAttr = getAttrByFwdIdentName(attrs, etype, label);
const revAttr = getAttrByReverseIdentName(attrs, etype, label);
return eids.map((eidB) => {
const txStep = fwdAttr
? [
'add-triple',
extractLookup(attrs, etype, eidA),
fwdAttr.id,
extractLookup(attrs, fwdAttr['reverse-identity'][1], eidB),
]
: [
'add-triple',
extractLookup(attrs, revAttr['forward-identity'][1], eidB),
revAttr.id,
extractLookup(attrs, etype, eidA),
];
return txStep;
});
});
return withIdAttrForLookup(attrs, etype, eidA, addTriples);
}
function expandUnlink({ attrs }, [etype, eidA, obj]) {
const retractTriples = Object.entries(obj).flatMap(([label, eidOrEids]) => {
const eids = Array.isArray(eidOrEids) ? eidOrEids : [eidOrEids];
const fwdAttr = getAttrByFwdIdentName(attrs, etype, label);
const revAttr = getAttrByReverseIdentName(attrs, etype, label);
return eids.map((eidB) => {
const txStep = fwdAttr
? [
'retract-triple',
extractLookup(attrs, etype, eidA),
fwdAttr.id,
extractLookup(attrs, fwdAttr['reverse-identity'][1], eidB),
]
: [
'retract-triple',
extractLookup(attrs, revAttr['forward-identity'][1], eidB),
revAttr.id,
extractLookup(attrs, etype, eidA),
];
return txStep;
});
});
return withIdAttrForLookup(attrs, etype, eidA, retractTriples);
}
function checkEntityExists(stores, etype, eid) {
if (Array.isArray(eid)) {
// lookup ref
const [entity_a, entity_v] = eid;
for (const store of stores || []) {
const ev = store?.aev.get(entity_a);
if (ev) {
// This would be a lot more efficient with a ave index
for (const [e_, a_, v] of allMapValues(ev, 2)) {
if (v === entity_v) {
return true;
}
}
}
}
} else {
// eid
for (const store of stores || []) {
const av = store?.eav.get(eid);
if (av) {
for (const attr_id of av.keys()) {
if (store.attrs[attr_id]['forward-identity'][1] == etype) {
return true;
}
}
}
}
}
return false;
}
function convertOpts({ stores, attrs }, [etype, eid, obj_, opts]) {
return opts?.upsert === false
? { mode: 'update' }
: opts?.upsert === true
? null
: checkEntityExists(stores, etype, eid)
? { mode: 'update' }
: null; // auto mode chooses between update and upsert, not update and create, just in case
}
function expandCreate(ctx, step) {
const { stores, attrs } = ctx;
const [etype, eid, obj_, opts] = step;
const obj = immutableRemoveUndefined(obj_);
const lookup = extractLookup(attrs, etype, eid);
// id first so that we don't clobber updates on the lookup field
const attrTuples = [['id', lookup]]
.concat(Object.entries(obj))
.map(([identName, value]) => {
const attr = getAttrByFwdIdentName(attrs, etype, identName);
return ['add-triple', lookup, attr.id, value, { mode: 'create' }];
});
return attrTuples;
}
function expandUpdate(ctx, step) {
const { stores, attrs } = ctx;
const [etype, eid, obj_, opts] = step;
const obj = immutableRemoveUndefined(obj_);
const lookup = extractLookup(attrs, etype, eid);
const serverOpts = convertOpts(ctx, [etype, lookup, obj_, opts]);
// id first so that we don't clobber updates on the lookup field
const attrTuples = [['id', lookup]]
.concat(Object.entries(obj))
.map(([identName, value]) => {
const attr = getAttrByFwdIdentName(attrs, etype, identName);
return [
'add-triple',
lookup,
attr.id,
value,
...(serverOpts ? [serverOpts] : []),
];
});
return attrTuples;
}
function expandDelete({ attrs }, [etype, eid]) {
const lookup = extractLookup(attrs, etype, eid);
return [['delete-entity', lookup, etype]];
}
function expandDeepMerge(ctx, step) {
const { stores, attrs } = ctx;
const [etype, eid, obj_, opts] = step;
const obj = immutableRemoveUndefined(obj_);
const lookup = extractLookup(attrs, etype, eid);
const serverOpts = convertOpts(ctx, [etype, lookup, obj_, opts]);
const attrTuples = Object.entries(obj).map(([identName, value]) => {
const attr = getAttrByFwdIdentName(attrs, etype, identName);
return [
'deep-merge-triple',
lookup,
attr.id,
value,
...(serverOpts ? [serverOpts] : []),
];
});
const idTuple = [
'add-triple',
lookup,
getAttrByFwdIdentName(attrs, etype, 'id').id,
lookup,
...(serverOpts ? [serverOpts] : []),
];
// id first so that we don't clobber updates on the lookup field
return [idTuple].concat(attrTuples);
}
function expandRuleParams({ attrs }, [etype, eid, ruleParams]) {
const lookup = extractLookup(attrs, etype, eid);
return [['rule-params', lookup, etype, ruleParams]];
}
function removeIdFromArgs(step) {
const [op, etype, eid, obj, opts] = step;
if (!obj) {
return step;
}
const newObj = { ...obj };
delete newObj.id;
return [op, etype, eid, newObj, ...(opts ? [opts] : [])];
}
function toTxSteps(ctx, step) {
const [action, ...args] = removeIdFromArgs(step);
switch (action) {
case 'merge':
return expandDeepMerge(ctx, args);
case 'create':
return expandCreate(ctx, args);
case 'update':
return expandUpdate(ctx, args);
case 'link':
return expandLink(ctx, args);
case 'unlink':
return expandUnlink(ctx, args);
case 'delete':
return expandDelete(ctx, args);
case 'ruleParams':
return expandRuleParams(ctx, args);
default:
throw new Error(`unsupported action ${action}`);
}
}
// ---------
// transform
function checkedDataTypeOfValueType(valueType) {
switch (valueType) {
case 'string':
case 'date':
case 'boolean':
case 'number':
return valueType;
default:
return undefined;
}
}
function objectPropsFromSchema(schema, etype, label) {
const attr = schema.entities[etype]?.attrs?.[label];
if (label === 'id') return null;
if (!attr) {
throw new Error(`${etype}.${label} does not exist in your schema`);
}
const { unique, indexed } = attr?.config;
const checkedDataType = checkedDataTypeOfValueType(attr?.valueType);
return {
'index?': indexed,
'unique?': unique,
'checked-data-type': checkedDataType,
};
}
function createObjectAttr(schema, etype, label, props) {
const schemaObjectProps = schema
? objectPropsFromSchema(schema, etype, label)
: null;
const attrId = uuid();
const fwdIdentId = uuid();
const fwdIdent = [fwdIdentId, etype, label];
return {
id: attrId,
'forward-identity': fwdIdent,
'value-type': 'blob',
cardinality: 'one',
'unique?': false,
'index?': false,
isUnsynced: true,
...(schemaObjectProps || {}),
...(props || {}),
};
}
function findSchemaLink(schema, etype, label) {
const found = Object.values(schema.links).find((x) => {
return (
(x.forward.on === etype && x.forward.label === label) ||
(x.reverse.on === etype && x.reverse.label === label)
);
});
return found;
}
function refPropsFromSchema(schema, etype, label) {
const found = findSchemaLink(schema, etype, label);
if (!found) {
throw new Error(`Couldn't find the link ${etype}.${label} in your schema`);
}
const { forward, reverse } = found;
return {
'forward-identity': [uuid(), forward.on, forward.label],
'reverse-identity': [uuid(), reverse.on, reverse.label],
cardinality: forward.has === 'one' ? 'one' : 'many',
'unique?': reverse.has === 'one',
};
}
function createRefAttr(schema, etype, label, props) {
const schemaRefProps = schema
? refPropsFromSchema(schema, etype, label)
: null;
const attrId = uuid();
const fwdIdent = [uuid(), etype, label];
const revIdent = [uuid(), label, etype];
return {
id: attrId,
'forward-identity': fwdIdent,
'reverse-identity': revIdent,
'value-type': 'ref',
cardinality: 'many',
'unique?': false,
'index?': false,
isUnsynced: true,
...(schemaRefProps || {}),
...(props || {}),
};
}
// Actions that have an object, e.g. not delete
const OBJ_ACTIONS = new Set(['create', 'update', 'merge', 'link', 'unlink']);
const REF_ACTIONS = new Set(['link', 'unlink']);
const UPDATE_ACTIONS = new Set(['create', 'update', 'merge']);
const SUPPORTS_LOOKUP_ACTIONS = new Set([
'link',
'unlink',
'create',
'update',
'merge',
'delete',
'ruleParams',
]);
const lookupProps = { 'unique?': true, 'index?': true };
const refLookupProps = { ...lookupProps, cardinality: 'one' };
function lookupPairsOfOp(op) {
const res = [];
const [action, etype, eid, obj] = op;
if (!SUPPORTS_LOOKUP_ACTIONS.has(action)) {
return res;
}
const eidLookupPair = lookupPairOfEid(eid);
if (eidLookupPair) {
res.push({ etype: etype, lookupPair: eidLookupPair });
}
if (action === 'link') {
for (const [label, eidOrEids] of Object.entries(obj)) {
const eids = Array.isArray(eidOrEids) ? eidOrEids : [eidOrEids];
for (const linkEid of eids) {
const linkEidLookupPair = lookupPairOfEid(linkEid);
if (linkEidLookupPair) {
res.push({
etype: etype,
lookupPair: linkEidLookupPair,
linkLabel: label,
});
}
}
}
}
return res;
}
function createMissingAttrs({ attrs: existingAttrs, schema }, ops) {
const [addedIds, attrs, addOps] = [new Set(), { ...existingAttrs }, []];
function addAttr(attr) {
attrs[attr.id] = attr;
addOps.push(['add-attr', attr]);
addedIds.add(attr.id);
}
function addUnsynced(attr) {
if (attr?.isUnsynced && !addedIds.has(attr.id)) {
addOps.push(['add-attr', attr]);
addedIds.add(attr.id);
}
}
// Adds attrs needed for a ref lookup
function addForRef(etype, label) {
const fwdAttr = getAttrByFwdIdentName(attrs, etype, label);
const revAttr = getAttrByReverseIdentName(attrs, etype, label);
addUnsynced(fwdAttr);
addUnsynced(revAttr);
if (!fwdAttr && !revAttr) {
addAttr(createRefAttr(schema, etype, label, refLookupProps));
}
}
// Create attrs for lookups if we need to
// Do these first because otherwise we might add a non-unique attr
// before we get to it
for (const op of ops) {
for (const { etype, lookupPair, linkLabel } of lookupPairsOfOp(op)) {
const identName = lookupPair[0];
// We got a link eid that's a lookup, linkLabel is the label of the ident,
// e.g. `posts` in `link({posts: postIds})`
if (linkLabel) {
// Add our ref attr, e.g. users.posts
addForRef(etype, linkLabel);
// Figure out the link etype so we can make sure we have the attrs
// for the link lookup
const fwdAttr = getAttrByFwdIdentName(attrs, etype, linkLabel);
const revAttr = getAttrByReverseIdentName(attrs, etype, linkLabel);
addUnsynced(fwdAttr);
addUnsynced(revAttr);
const linkEtype =
fwdAttr?.['reverse-identity']?.[1] ||
revAttr?.['forward-identity']?.[1] ||
linkLabel;
if (isRefLookupIdent(attrs, linkEtype, identName)) {
addForRef(linkEtype, extractRefLookupFwdName(identName));
} else {
const attr = getAttrByFwdIdentName(attrs, linkEtype, identName);
if (!attr) {
addAttr(
createObjectAttr(schema, linkEtype, identName, lookupProps),
);
}
addUnsynced(attr);
}
} else if (isRefLookupIdent(attrs, etype, identName)) {
addForRef(etype, extractRefLookupFwdName(identName));
} else {
const attr = getAttrByFwdIdentName(attrs, etype, identName);
if (!attr) {
addAttr(createObjectAttr(schema, etype, identName, lookupProps));
}
addUnsynced(attr);
}
}
}
// Create object and ref attrs
for (const op of ops) {
const [action, etype, eid, obj] = op;
if (OBJ_ACTIONS.has(action)) {
const labels = Object.keys(obj);
labels.push('id');
for (const label of labels) {
const fwdAttr = getAttrByFwdIdentName(attrs, etype, label);
addUnsynced(fwdAttr);
if (UPDATE_ACTIONS.has(action)) {
if (!fwdAttr) {
addAttr(
createObjectAttr(
schema,
etype,
label,
label === 'id' ? { 'unique?': true } : null,
),
);
}
}
if (REF_ACTIONS.has(action)) {
const revAttr = getAttrByReverseIdentName(attrs, etype, label);
if (!fwdAttr && !revAttr) {
addAttr(createRefAttr(schema, etype, label));
}
addUnsynced(revAttr);
}
}
}
}
return [attrs, addOps];
}
export function transform(ctx, inputChunks) {
const chunks = Array.isArray(inputChunks) ? inputChunks : [inputChunks];
const ops = chunks.flatMap((tx) => getOps(tx));
const [newAttrs, addAttrTxSteps] = createMissingAttrs(ctx, ops);
const newCtx = { ...ctx, attrs: newAttrs };
const txSteps = ops.flatMap((op) => toTxSteps(newCtx, op));
return [...addAttrTxSteps, ...txSteps];
}