@instantdb/core
Version:
Instant's core local abstraction
564 lines • 21.3 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SyncTable = exports.CallbackEventType = void 0;
const PersistedObject_ts_1 = require("./utils/PersistedObject.js");
const s = __importStar(require("./store.js"));
const weakHash_ts_1 = __importDefault(require("./utils/weakHash.js"));
const id_ts_1 = __importDefault(require("./utils/id.js"));
const instaql_ts_1 = __importStar(require("./instaql.js"));
// Modifies the data in place because it comes directly from storage
function syncSubFromStorage(sub, useDateObjects) {
const values = sub.values;
if (values) {
const attrsStore = s.attrsStoreFromJSON(values.attrsStore, null);
if (attrsStore) {
for (const e of values.entities || []) {
e.store.useDateObjects = useDateObjects;
e.store = s.fromJSON(attrsStore, e.store);
}
values.attrsStore = attrsStore;
}
}
return sub;
}
function syncSubToStorage(_k, sub) {
if (sub.values) {
const entities = [];
for (const e of sub.values?.entities) {
const store = s.toJSON(e.store);
entities.push({ ...e, store });
}
return {
...sub,
values: { attrsStore: sub.values.attrsStore.toJSON(), entities },
};
}
else {
return sub;
}
}
function onMergeSub(_key, storageSub, inMemorySub) {
const storageTxId = storageSub?.state?.txId;
const memoryTxId = inMemorySub?.state?.txId;
if (storageTxId && (!memoryTxId || storageTxId > memoryTxId)) {
return storageSub;
}
if (memoryTxId && (!storageTxId || memoryTxId > storageTxId)) {
return inMemorySub;
}
return storageSub || inMemorySub;
}
function queryEntity(sub, store, attrsStore) {
const res = (0, instaql_ts_1.default)({ store, attrsStore, pageInfo: null, aggregate: null }, sub.query);
return res.data[sub.table][0];
}
function getServerCreatedAt(sub, store, attrsStore, entityId) {
const aid = s.getAttrByFwdIdentName(attrsStore, sub.table, 'id')?.id;
if (!aid) {
return -1;
}
const t = s.getInMap(store.eav, [entityId, aid, entityId]);
if (!t) {
return -1;
}
return t[3];
}
function applyChangesToStore(store, attrsStore, changes) {
for (const { action, triple } of changes) {
switch (action) {
case 'added':
s.addTriple(store, attrsStore, triple);
break;
case 'removed':
s.retractTriple(store, attrsStore, triple);
break;
}
}
}
function changedFieldsOfChanges(store, attrsStore, changes) {
// This will be more complicated when we include links, we can either add a
// changedLinks field or we can have something like 'bookshelves.title`
const changedFields = {};
for (const { action, triple } of changes) {
const [e, a, v] = triple;
const field = attrsStore.getAttr(a)?.['forward-identity']?.[2];
if (!field)
continue;
const fields = changedFields[e] ?? {};
changedFields[e] = fields;
const oldNew = fields[field] ?? {};
switch (action) {
case 'added':
oldNew.newValue = v;
break;
case 'removed':
// Only take the first thing that was removed, in case we modified things in the middle
if (oldNew.oldValue === undefined) {
oldNew.oldValue = v;
}
break;
}
fields[field] = oldNew;
}
for (const [_eid, fields] of Object.entries(changedFields)) {
for (const [k, { oldValue, newValue }] of Object.entries(fields)) {
if (oldValue === newValue) {
delete fields[k];
}
}
}
return changedFields;
}
function subData(sub, entities) {
return { [sub.table]: entities.map((e) => e.entity) };
}
// Updates the sub order field type if it hasn't been set
// and returns the type. We have to wait until the attrs
// are loaded before we can determine the type.
function orderFieldTypeMutative(sub, getAttrs) {
if (sub.orderFieldType) {
return sub.orderFieldType;
}
const orderFieldType = sub.orderField === 'serverCreatedAt'
? 'number'
: s.getAttrByFwdIdentName(getAttrs(), sub.table, sub.orderField)?.['checked-data-type'];
sub.orderFieldType = orderFieldType;
return orderFieldType;
}
function sortEntitiesInPlace(sub, orderFieldType, entities) {
const dataType = orderFieldType;
if (sub.orderField === 'serverCreatedAt') {
entities.sort(sub.orderDirection === 'asc'
? function compareEntities(a, b) {
return (0, instaql_ts_1.compareOrder)(a.entity.id, a.serverCreatedAt, b.entity.id, b.serverCreatedAt, dataType);
}
: function compareEntities(b, a) {
return (0, instaql_ts_1.compareOrder)(a.entity.id, a.serverCreatedAt, b.entity.id, b.serverCreatedAt, dataType);
});
return;
}
const field = sub.orderField;
entities.sort(sub.orderDirection === 'asc'
? function compareEntities(a, b) {
return (0, instaql_ts_1.compareOrder)(a.entity.id, a.entity[field], b.entity.id, b.entity[field], dataType);
}
: function compareEntities(b, a) {
return (0, instaql_ts_1.compareOrder)(a.entity.id, a.entity[field], b.entity.id, b.entity[field], dataType);
});
}
var CallbackEventType;
(function (CallbackEventType) {
CallbackEventType["InitialSyncBatch"] = "InitialSyncBatch";
CallbackEventType["InitialSyncComplete"] = "InitialSyncComplete";
CallbackEventType["LoadFromStorage"] = "LoadFromStorage";
CallbackEventType["SyncTransaction"] = "SyncTransaction";
CallbackEventType["Error"] = "Error";
})(CallbackEventType || (exports.CallbackEventType = CallbackEventType = {}));
class SyncTable {
trySend;
subs;
// Using any for the SyncCallback because we'd need Reactor to be typed
callbacks = {};
config;
idToHash = {};
log;
createStore;
getAttrs;
constructor(trySend, storage, config, log, createStore, getAttrs) {
this.trySend = trySend;
this.config = config;
this.log = log;
this.createStore = createStore;
this.getAttrs = getAttrs;
this.subs = new PersistedObject_ts_1.PersistedObject({
persister: storage,
merge: onMergeSub,
serialize: syncSubToStorage,
parse: (_key, x) => syncSubFromStorage(x, this.config.useDateObjects),
objectSize: (sub) => sub.values?.entities.length || 0,
logger: log,
gc: {
maxAgeMs: 1000 * 60 * 60 * 24 * 7 * 52, // 1 year
maxEntries: 1000,
// Size of each sub is the number of entity
maxSize: 1_000_000, // 1 million entities
},
});
}
beforeUnload() {
this.subs.flush();
}
subscribe(q, cb) {
const hash = (0, weakHash_ts_1.default)(q);
this.callbacks[hash] = this.callbacks[hash] || [];
this.callbacks[hash].push(cb);
this.initSubscription(q, hash, cb);
return (opts) => {
this.unsubscribe(hash, cb, opts?.keepSubscription);
};
}
unsubscribe(hash, cb, keepSubscription) {
const cbs = (this.callbacks[hash] || []).filter((x) => x !== cb);
this.callbacks[hash] = cbs;
if (!cbs.length) {
delete this.callbacks[hash];
const sub = this.subs.currentValue[hash];
if (sub?.state) {
this.clearSubscriptionData(sub.state.subscriptionId, !!keepSubscription);
}
if (!keepSubscription) {
this.subs.updateInPlace((prev) => {
delete prev[hash];
});
}
}
}
sendStart(query) {
this.trySend((0, id_ts_1.default)(), {
op: 'start-sync',
q: query,
});
}
sendResync(sub, state, txId) {
// Make sure we can find the hash from the subscriptionId
this.idToHash[state.subscriptionId] = sub.hash;
this.trySend(state.subscriptionId, {
op: 'resync-table',
'subscription-id': state.subscriptionId,
'tx-id': txId,
token: state.token,
});
}
sendRemove(state, keepSubscription) {
this.trySend((0, id_ts_1.default)(), {
op: 'remove-sync',
'subscription-id': state.subscriptionId,
'keep-subscription': keepSubscription,
});
}
async initSubscription(query, hash, cb) {
// Wait for storage to load so that we know if we already have an existing subscription
await this.subs.waitForKeyToLoad(hash);
const existingSub = this.subs.currentValue[hash];
if (existingSub && existingSub.state && existingSub.state.txId) {
this.sendResync(existingSub, existingSub.state, existingSub.state.txId);
if (existingSub.values?.entities && cb) {
cb({
type: CallbackEventType.LoadFromStorage,
data: subData(existingSub, existingSub.values?.entities),
});
}
return;
}
const table = Object.keys(query)[0];
const orderBy = query[table]?.$?.order || { serverCreatedAt: 'asc' };
const [orderField, orderDirection] = Object.entries(orderBy)[0];
this.subs.updateInPlace((prev) => {
prev[hash] = {
query,
hash: hash,
table,
orderDirection,
orderField,
createdAt: Date.now(),
updatedAt: Date.now(),
};
});
this.sendStart(query);
}
async flushPending() {
for (const hash of Object.keys(this.callbacks)) {
await this.subs.waitForKeyToLoad(hash);
const sub = this.subs.currentValue[hash];
if (sub) {
await this.initSubscription(sub.query, sub.hash);
}
else {
this.log.error('Missing sub for hash in flushPending', hash);
}
}
}
onStartSyncOk(msg) {
const subscriptionId = msg['subscription-id'];
const q = msg.q;
const hash = (0, weakHash_ts_1.default)(q);
this.idToHash[subscriptionId] = hash;
this.subs.updateInPlace((prev) => {
const sub = prev[hash];
if (!sub) {
this.log.error('Missing sub for hash', hash, 'subscription-id', subscriptionId, 'query', q);
return prev;
}
sub.state = {
subscriptionId: subscriptionId,
token: msg.token,
};
});
}
notifyCbs(hash, event) {
for (const cb of this.callbacks[hash] || []) {
cb(event);
}
}
onSyncLoadBatch(msg) {
const subscriptionId = msg['subscription-id'];
const joinRows = msg['join-rows'];
const hash = this.idToHash[subscriptionId];
if (!hash) {
this.log.error('Missing hash for subscription', msg);
return;
}
const batch = [];
const sub = this.subs.currentValue[hash];
if (!sub) {
this.log.error('Missing sub for hash', hash, msg);
return;
}
const values = sub.values ?? {
entities: [],
attrsStore: this.getAttrs(),
};
sub.values = values;
const entities = values.entities;
for (const entRows of joinRows) {
const store = this.createStore(entRows);
const entity = queryEntity(sub, store, values.attrsStore);
entities.push({
store,
entity,
serverCreatedAt: getServerCreatedAt(sub, store, values.attrsStore, entity.id),
});
batch.push(entity);
}
this.subs.updateInPlace((prev) => {
prev[hash] = sub;
// Make sure we write a field or mutative won't
// see the change because sub === prev[hash]
prev[hash].updatedAt = Date.now();
});
if (sub.values) {
this.notifyCbs(hash, {
type: CallbackEventType.InitialSyncBatch,
data: subData(sub, sub.values.entities),
batch,
});
}
}
onSyncInitFinish(msg) {
const subscriptionId = msg['subscription-id'];
const hash = this.idToHash[subscriptionId];
if (!hash) {
this.log.error('Missing hash for subscription', msg);
return;
}
this.subs.updateInPlace((prev) => {
const sub = prev[hash];
if (!sub) {
this.log.error('Missing sub for hash', hash, msg);
return;
}
const state = sub.state;
if (!state) {
this.log.error('Sub never set init, missing result', sub, msg);
return prev;
}
state.txId = msg['tx-id'];
sub.updatedAt = Date.now();
});
const sub = this.subs.currentValue[hash];
if (sub) {
this.notifyCbs(hash, {
type: CallbackEventType.InitialSyncComplete,
data: subData(sub, sub.values?.entities || []),
});
}
}
onSyncUpdateTriples(msg) {
const subscriptionId = msg['subscription-id'];
const hash = this.idToHash[subscriptionId];
if (!hash) {
this.log.error('Missing hash for subscription', msg);
return;
}
const sub = this.subs.currentValue[hash];
if (!sub) {
this.log.error('Missing sub for hash', hash, msg);
return;
}
const state = sub.state;
if (!state) {
this.log.error('Missing state for sub', sub, msg);
return;
}
for (const tx of msg.txes) {
if (state.txId && state.txId >= tx['tx-id']) {
continue;
}
state.txId = tx['tx-id'];
const idxesToDelete = [];
// Note: this won't work as well when links are involved
const byEid = {};
for (const change of tx.changes) {
const eidChanges = byEid[change.triple[0]] ?? [];
byEid[change.triple[0]] = eidChanges;
eidChanges.push(change);
}
const values = sub.values ?? {
entities: [],
attrsStore: this.getAttrs(),
};
const entities = values.entities;
sub.values = values;
const updated = [];
// Update the existing stores, if we already know about this entity
eidLoop: for (const [eid, changes] of Object.entries(byEid)) {
for (let i = 0; i < entities.length; i++) {
const ent = entities[i];
if (s.hasEntity(ent.store, eid)) {
applyChangesToStore(ent.store, values.attrsStore, changes);
const entity = queryEntity(sub, ent.store, values.attrsStore);
const changedFields = changedFieldsOfChanges(ent.store, values.attrsStore, changes)[eid];
if (entity) {
updated.push({
oldEntity: ent.entity,
newEntity: entity,
changedFields: (changedFields || {}),
});
ent.entity = entity;
}
else {
idxesToDelete.push(i);
}
delete byEid[eid];
continue eidLoop;
}
}
}
const added = [];
// If we have anything left in byEid, then this must be a new entity we don't know about
for (const [_eid, changes] of Object.entries(byEid)) {
const store = this.createStore([]);
applyChangesToStore(store, values.attrsStore, changes);
const entity = queryEntity(sub, store, values.attrsStore);
if (!entity) {
this.log.error('No entity found after applying change', {
sub,
changes,
store,
});
continue;
}
entities.push({
store,
entity,
serverCreatedAt: getServerCreatedAt(sub, store, values.attrsStore, entity.id),
});
added.push(entity);
}
const removed = [];
for (const idx of idxesToDelete.sort().reverse()) {
removed.push(entities[idx].entity);
entities.splice(idx, 1);
}
const orderFieldType = orderFieldTypeMutative(sub, this.getAttrs);
sortEntitiesInPlace(sub, orderFieldType, entities);
this.notifyCbs(hash, {
type: CallbackEventType.SyncTransaction,
data: subData(sub, sub.values?.entities),
added,
removed,
updated,
});
}
this.subs.updateInPlace((prev) => {
prev[hash] = sub;
// Make sure we write a field or mutative won't
// see the change because sub === prev[hash]
prev[hash].updatedAt = Date.now();
});
}
clearSubscriptionData(subscriptionId, keepSubscription) {
const hash = this.idToHash[subscriptionId];
if (hash) {
delete this.idToHash[subscriptionId];
const sub = this.subs.currentValue[hash];
if (sub.state) {
this.sendRemove(sub.state, keepSubscription);
}
if (keepSubscription) {
this.subs.unloadKey(hash);
}
else {
this.subs.updateInPlace((prev) => {
delete prev[hash];
});
}
if (sub) {
return sub;
}
}
}
onStartSyncError(msg) {
const hash = (0, weakHash_ts_1.default)(msg['original-event']['q']);
const error = {
message: msg.message || 'Uh-oh, something went wrong. Ping Joe & Stopa.',
status: msg.status,
type: msg.type,
hint: msg.hint,
};
const k = Object.keys(msg['original-event']['q'])[0];
this.notifyCbs(hash, {
type: CallbackEventType.Error,
data: { [k]: [] },
error,
});
}
onResyncError(msg) {
// Clear the subscription and start from scrath on any resync error
// This can happen if the auth changed and we need to refetch with the
// new auth or if the subscription is too far behind.
const subscriptionId = msg['original-event']['subscription-id'];
const removedSub = this.clearSubscriptionData(subscriptionId, false);
if (removedSub) {
this.initSubscription(removedSub.query, removedSub.hash);
}
}
}
exports.SyncTable = SyncTable;
//# sourceMappingURL=SyncTable.js.map