UNPKG

@signaldb/sync

Version:

This is the sync implementation of [SignalDB](https://github.com/maxnowack/signaldb). SignalDB is a local-first JavaScript database with real-time sync, enabling optimistic UI with signal-based reactivity across multiple frameworks.

589 lines (587 loc) 19.8 kB
var $ = Object.defineProperty; var E = (r, e, t) => e in r ? $(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t; var d = (r, e, t) => E(r, typeof e != "symbol" ? e + "" : e, t); import { isEqual as S, modify as C, randomId as D, Collection as m, createIndex as y } from "@signaldb/core"; function I(r, e, t = {}) { let s, n; const { leading: a = !1, trailing: o = !0 } = t; function c(...h) { const i = a && !s, u = o && !s; return s && clearTimeout(s), s = setTimeout(() => { s = null, o && !i && (n = r.apply(this, h)); }, e), i ? n = r.apply(this, h) : u || (n = null), n; } return c; } class M { constructor() { d(this, "queue", []); d(this, "pendingPromise", !1); } /** * Method to add a new promise to the queue and returns a promise that resolves when this task is done * @param task Function that returns a promise that will be added to the queue * @returns Promise that resolves when the task is done */ add(e) { return new Promise((t, s) => { this.queue.push(() => e().then(t).catch((n) => { throw s(n), n; })), this.dequeue(); }); } /** * Method to check if there is a pending promise in the queue * @returns True if there is a pending promise, false otherwise */ hasPendingPromise() { return this.pendingPromise; } /** * Method to process the queue */ dequeue() { if (this.pendingPromise || this.queue.length === 0) return; const e = this.queue.shift(); e && (this.pendingPromise = !0, e().then(() => { this.pendingPromise = !1, this.dequeue(); }).catch(() => { this.pendingPromise = !1, this.dequeue(); })); } } function g(r, e) { const t = [], s = [], n = [], a = new Map(r.map((c) => [c.id, c])), o = new Map(e.map((c) => [c.id, c])); for (const [c, h] of a) { const i = o.get(c); i ? S(i, h) || s.push(i) : n.push(h); } for (const [c, h] of o) a.has(c) || t.push(h); return { added: t, modified: s, removed: n }; } function P(r, e) { if (e.items != null) return e.items; const t = r || []; return e.changes.added.forEach((s) => { const n = t.findIndex((a) => a.id === s.id); n === -1 ? t.push(s) : t[n] = s; }), e.changes.modified.forEach((s) => { const n = t.findIndex((a) => a.id === s.id); n === -1 ? t.push(s) : t[n] = s; }), e.changes.removed.forEach((s) => { const n = t.findIndex((a) => a.id === s.id); n !== -1 && t.splice(n, 1); }), t; } function v(r, e) { const t = new Map(r.map((s) => [s.id, s])); return e.forEach((s) => { if (s.type === "remove") t.delete(s.data); else if (s.type === "insert") { const n = t.get(s.data.id); t.set(s.data.id, n ? { ...n, ...s.data } : s.data); } else { const n = t.get(s.data.id); t.set(s.data.id, n ? C(n, s.data.modifier) : C({ id: s.data.id }, s.data.modifier)); } }), [...t.values()]; } function N(r) { return r.added.length > 0 || r.modified.length > 0 || r.removed.length > 0; } function x(r, e) { return N(g(r, e)); } async function R({ changes: r, lastSnapshot: e, data: t, pull: s, push: n, insert: a, update: o, remove: c, batch: h }) { let i = t, u = e || [], f = P(e, i); if (r.length > 0) { const p = v(u, r); if (x(u, p)) { const O = v(f, r), w = g(f, O); N(w) && (await n(w), i = await s(), f = P(f, i)), u = p; } } const l = i.changes == null ? g(u, i.items) : i.changes; return h(() => { l.added.forEach((p) => a(p)), l.modified.forEach((p) => o(p.id, { $set: p })), l.removed.forEach((p) => c(p.id)); }), f; } class b { /** * @param options Collection options * @param options.pull Function to pull data from remote source. * @param options.push Function to push data to remote source. * @param [options.registerRemoteChange] Function to register a callback for remote changes. * @param [options.id] Unique identifier for this sync manager. Only nessesary if you have multiple sync managers. * @param [options.persistenceAdapter] Persistence adapter to use for storing changes, snapshots and sync operations. * @param [options.reactivity] Reactivity adapter to use for reactivity. * @param [options.onError] Function to handle errors that occur async during syncing. * @param [options.autostart] Whether to automatically start syncing new collections. * @param [options.debounceTime] The time in milliseconds to debounce push operations. */ constructor(e) { d(this, "options"); d(this, "collections", /* @__PURE__ */ new Map()); d(this, "changes"); d(this, "snapshots"); d(this, "syncOperations"); d(this, "scheduledPushes", /* @__PURE__ */ new Set()); d(this, "remoteChanges", []); d(this, "syncQueues", /* @__PURE__ */ new Map()); d(this, "persistenceReady"); d(this, "isDisposed", !1); d(this, "instanceId", D()); d(this, "id"); d(this, "debouncedFlush"); this.options = { autostart: !0, ...e }, this.id = this.options.id || "default-sync-manager"; const { reactivity: t } = this.options, s = this.createPersistenceAdapter("changes"), n = this.createPersistenceAdapter("snapshots"), a = this.createPersistenceAdapter("sync-operations"); this.changes = new m({ name: `${this.options.id}-changes`, persistence: s == null ? void 0 : s.adapter, indices: [y("collectionName")], reactivity: t }), this.snapshots = new m({ name: `${this.options.id}-snapshots`, persistence: n == null ? void 0 : n.adapter, indices: [y("collectionName")], reactivity: t }), this.syncOperations = new m({ name: `${this.options.id}-sync-operations`, persistence: a == null ? void 0 : a.adapter, indices: [y("collectionName"), y("status")], reactivity: t }), this.changes.on("persistence.error", (o) => s == null ? void 0 : s.handler(o)), this.snapshots.on("persistence.error", (o) => n == null ? void 0 : n.handler(o)), this.syncOperations.on("persistence.error", (o) => a == null ? void 0 : a.handler(o)), this.persistenceReady = Promise.all([ this.syncOperations.isReady(), this.changes.isReady(), this.snapshots.isReady() ]).then(() => { }), this.changes.setMaxListeners(1e3), this.snapshots.setMaxListeners(1e3), this.syncOperations.setMaxListeners(1e3), this.debouncedFlush = I(this.flushScheduledPushes, this.options.debounceTime ?? 100); } createPersistenceAdapter(e) { if (this.options.persistenceAdapter == null) return; let t = () => { }; return { adapter: this.options.persistenceAdapter(`${this.id}-${e}`, (n) => { t = n; }), handler: (n) => t(n) }; } getSyncQueue(e) { return this.syncQueues.get(e) == null && this.syncQueues.set(e, new M()), this.syncQueues.get(e); } /** * Clears all internal data structures */ async dispose() { this.collections.clear(), this.syncQueues.clear(), this.remoteChanges.splice(0, this.remoteChanges.length), await Promise.all([ this.changes.dispose(), this.snapshots.dispose(), this.syncOperations.dispose() ]), this.isDisposed = !0; } /** * Gets a collection with it's options by name * @deprecated Use getCollectionProperties instead. * @param name Name of the collection * @throws Will throw an error if the name wasn't found * @returns Tuple of collection and options */ getCollection(e) { const { collection: t, options: s } = this.getCollectionProperties(e); return [t, s]; } /** * Gets collection options by name * @param name Name of the collection * @throws Will throw an error if the name wasn't found * @returns An object of all properties of the collection */ getCollectionProperties(e) { const t = this.collections.get(e); if (t == null) throw new Error(`Collection with id '${e}' not found`); return t; } /** * Adds a collection to the sync manager. * @param collection Collection to add * @param options Options for the collection. The object needs at least a `name` property. * @param options.name Unique name of the collection */ addCollection(e, t) { if (this.isDisposed) throw new Error("SyncManager is disposed"); this.collections.set(t.name, { collection: e, options: t, readyPromise: e.isReady(), syncPaused: !0 // always start paused as the autostart will start it }); const s = (a) => { for (const o of this.remoteChanges) if (o != null && o.collectionName === a.collectionName && o.type === a.type && !(a.type === "remove" && o.data !== a.data) && o.data.id === a.data.id) return !0; return !1; }, n = (a, o) => { const c = [...this.remoteChanges]; for (let h = 0; h < c.length; h += 1) { const i = c[h]; i != null && i.collectionName === a && (i.type === "remove" && i.data !== o || i.data.id === o && (c[h] = null)); } this.remoteChanges = c.filter((h) => h != null); }; e.on("added", (a) => { if (s({ collectionName: t.name, type: "insert", data: a })) { n(t.name, a.id); return; } this.changes.insert({ collectionName: t.name, time: Date.now(), type: "insert", data: a }), !this.getCollectionProperties(t.name).syncPaused && this.schedulePush(t.name); }), e.on("changed", ({ id: a }, o) => { const c = { id: a, modifier: o }; if (s({ collectionName: t.name, type: "update", data: c })) { n(t.name, a); return; } this.changes.insert({ collectionName: t.name, time: Date.now(), type: "update", data: c }), !this.getCollectionProperties(t.name).syncPaused && this.schedulePush(t.name); }), e.on("removed", ({ id: a }) => { if (s({ collectionName: t.name, type: "remove", data: a })) { n(t.name, a); return; } this.changes.insert({ collectionName: t.name, time: Date.now(), type: "remove", data: a }), !this.getCollectionProperties(t.name).syncPaused && this.schedulePush(t.name); }), this.options.autostart && this.startSync(t.name).catch((a) => { this.options.onError && this.options.onError(this.getCollectionProperties(t.name).options, a); }); } flushScheduledPushes() { this.scheduledPushes.forEach((e) => { this.pushChanges(e).catch(() => { }); }), this.scheduledPushes.clear(); } schedulePush(e) { this.scheduledPushes.add(e), this.debouncedFlush(); } /** * Setup all collections to be synced with remote changes * and enable automatic pushing changes to the remote source. */ async startAll() { await Promise.all([...this.collections.keys()].map((e) => this.startSync(e))); } /** * Setup a collection to be synced with remote changes * and enable automatic pushing changes to the remote source. * @param name Name of the collection */ async startSync(e) { const t = this.getCollectionProperties(e); if (!t.syncPaused) return; this.schedulePush(e); const s = this.options.registerRemoteChange ? await this.options.registerRemoteChange(t.options, async (n) => { if (n == null) await this.sync(e); else { const a = Date.now(), o = this.syncOperations.insert({ start: a, collectionName: e, instanceId: this.instanceId, status: "active" }); await this.syncWithData(e, n).then(() => { this.syncOperations.removeMany({ id: { $ne: o }, collectionName: e, $or: [ { end: { $lte: a } }, { status: "active" } ] }), this.syncOperations.updateOne({ id: o }, { $set: { status: "done", end: Date.now() } }); }).catch((c) => { throw this.options.onError && this.options.onError(this.getCollectionProperties(e).options, c), this.syncOperations.updateOne({ id: o }, { $set: { status: "error", end: Date.now(), error: c.stack || c.message } }), c; }); } }) : void 0; this.collections.set(e, { ...t, syncPaused: !1, cleanupFunction: s }); } /** * Pauses the sync process for all collections. * This means that the collections will not be synced with remote changes * and changes will not automatically be pushed to the remote source. */ async pauseAll() { await Promise.all([...this.collections.keys()].map((e) => this.pauseSync(e))); } /** * Pauses the sync process for a collection. * This means that the collection will not be synced with remote changes * and changes will not automatically be pushed to the remote source. * @param name Name of the collection */ async pauseSync(e) { const t = this.getCollectionProperties(e); t.syncPaused || (t.cleanupFunction && await t.cleanupFunction(), this.collections.set(e, { ...t, cleanupFunction: void 0, syncPaused: !0 })); } /** * Starts the sync process for all collections */ async syncAll() { if (this.isDisposed) throw new Error("SyncManager is disposed"); const e = []; if (await Promise.all([...this.collections.keys()].map((t) => this.sync(t).catch((s) => { e.push({ id: t, error: s }); }))), e.length > 0) throw new Error(`Error while syncing collections: ${e.map((t) => `${t.id}: ${t.error.message}`).join(` `)}`); } /** * Checks if a collection is currently beeing synced * @param [name] Name of the collection. If not provided, it will check if any collection is currently beeing synced. * @returns True if the collection is currently beeing synced, false otherwise. */ isSyncing(e) { return this.syncOperations.findOne({ ...e ? { collectionName: e } : {}, status: "active" }, { fields: { status: 1 } }) != null; } /** * Checks if the sync manager is ready to sync. * @returns A promise that resolves when the sync manager is ready to sync. */ async isReady() { await this.persistenceReady; } /** * Starts the sync process for a collection * @param name Name of the collection * @param options Options for the sync process. * @param options.force If true, the sync process will be started even if there are no changes and onlyWithChanges is true. * @param options.onlyWithChanges If true, the sync process will only be started if there are changes. */ async sync(e, t = {}) { if (this.isDisposed) throw new Error("SyncManager is disposed"); await this.isReady(); const { options: s, readyPromise: n } = this.getCollectionProperties(e); await n; const a = this.syncOperations.find({ collectionName: e, instanceId: this.instanceId, status: "active" }, { reactive: !1 }).count() > 0, o = Date.now(); let c = null; await new Promise((i) => { setTimeout(i, 0); }); const h = async () => { const i = this.syncOperations.findOne({ collectionName: e, status: "done" }, { sort: { end: -1 }, reactive: !1 }); if (t != null && t.onlyWithChanges && this.changes.find({ collectionName: e, time: { $lte: o } }, { sort: { time: 1 }, reactive: !1 }).count() === 0) return; a || (c = this.syncOperations.insert({ start: o, collectionName: e, instanceId: this.instanceId, status: "active" })); const u = await this.options.pull(s, { lastFinishedSyncStart: i == null ? void 0 : i.start, lastFinishedSyncEnd: i == null ? void 0 : i.end }); await this.syncWithData(e, u); }; await (t != null && t.force ? h() : this.getSyncQueue(e).add(h)).catch((i) => { throw c != null && (this.options.onError && this.options.onError(s, i), this.syncOperations.updateOne({ id: c }, { $set: { status: "error", end: Date.now(), error: i.stack || i.message } })), i; }), c != null && (this.syncOperations.removeMany({ id: { $ne: c }, collectionName: e, $or: [ { end: { $lte: o } }, { status: "active" } ] }), this.syncOperations.updateOne({ id: c }, { $set: { status: "done", end: Date.now() } })); } /** * Starts the push process for a collection (sync process but only if there are changes) * @param name Name of the collection */ async pushChanges(e) { await this.sync(e, { onlyWithChanges: !0 }); } async syncWithData(e, t) { const { collection: s, options: n } = this.getCollectionProperties(e), a = Date.now(), o = this.syncOperations.findOne({ collectionName: e, status: "done" }, { sort: { end: -1 }, reactive: !1 }), c = this.snapshots.findOne({ collectionName: e }, { sort: { time: -1 }, reactive: !1 }), h = this.changes.find({ collectionName: e, time: { $lte: a } }, { sort: { time: 1 }, reactive: !1 }).fetch(); await R({ changes: h, lastSnapshot: c == null ? void 0 : c.items, data: t, pull: () => this.options.pull(n, { lastFinishedSyncStart: o == null ? void 0 : o.start, lastFinishedSyncEnd: o == null ? void 0 : o.end }), push: (i) => this.options.push(n, { changes: i }), insert: (i) => { this.remoteChanges.push({ collectionName: e, type: "insert", data: i }, { collectionName: e, type: "update", data: { id: i.id, modifier: { $set: i } } }), s.replaceOne({ id: i.id }, i, { upsert: !0 }); }, update: (i, u) => { this.remoteChanges.push({ collectionName: e, type: "insert", data: { id: i, ...u.$set } }, { collectionName: e, type: "update", data: { id: i, modifier: u } }), s.updateOne({ id: i }, { ...u, $setOnInsert: { id: i } }, { upsert: !0 }); }, remove: (i) => { s.findOne({ id: i }, { reactive: !1 }) && (this.remoteChanges.push({ collectionName: e, type: "remove", data: i }), s.removeOne({ id: i })); }, batch: (i) => { s.batch(() => { i(); }); } }).then(async (i) => { if (this.snapshots.removeMany({ collectionName: e, time: { $lte: a } }), this.changes.removeMany({ collectionName: e, id: { $in: h.map((l) => l.id) } }), this.snapshots.insert({ time: a, collectionName: e, items: i }), await new Promise((l) => { setTimeout(l, 0); }), this.changes.find({ collectionName: e }, { reactive: !1 }).count() > 0) { await this.sync(e, { force: !0, onlyWithChanges: !0 }); return; } const f = s.find({ id: { $nin: i.map((l) => l.id) } }, { reactive: !1 }).map((l) => l.id); s.batch(() => { i.forEach((l) => { this.remoteChanges.push({ collectionName: e, type: "insert", data: l }, { collectionName: e, type: "update", data: { id: l.id, modifier: { $set: l } } }), s.replaceOne({ id: l.id }, l, { upsert: !0 }); }), f.forEach((l) => { s.removeOne({ id: l }); }); }); }); } } export { b as SyncManager }; //# sourceMappingURL=index.mjs.map