UNPKG

@instantdb/core

Version:
564 lines • 21.3 kB
"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