@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
JavaScript
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