UNPKG

odious

Version:

Odious fundamentally revolutionizes the software development paradigm by seamlessly integrating deployment considerations into the development process, thus empowering developers of all skill levels to create and deploy applications with unprecedented eas

226 lines (191 loc) 7.97 kB
export default class Synchronizable { #debounce = 10; // Class member descriptions #revision; // Tracks the version number of this value, incremented on each change #revisionUuid; // Unique identifier for each revision, used for conflict resolution #unserializedValue; // The actual value stored in its native JavaScript form #serializedValue; // JSON string representation of the value for comparison // Internal implementation details #debounceTimeout; // Timer reference for debounced notifications #listeners; // Set of callback functions to notify when value changes constructor(value=undefined, revision=1, revisionUuid) { // Validate input parameters if (revision !== undefined && (!Number.isInteger(revision) || isNaN(revision))) { throw new Error("Revision must be an integer"); } if (revision !== undefined && revision < 1) { throw new Error("Revision must be 1 or greater (zero-indexing not supported)"); } this.#revision = revision; this.#revisionUuid = revisionUuid || generateTimestampedUUID(); // User provided (for edge cases) or generated this.#unserializedValue = value; // Will be serialized/deserialized using JSON this.#serializedValue = JSON.stringify(value); // Initialize internal state this.#listeners = new Set(); } /** * Register a listener function to be called when the value changes * @param {Function} listener - Callback function receiving (newValue, oldValue) * @returns {Function} Unsubscribe function to remove this listener */ subscribe(listener) { this.#listeners.add(listener); // Don't initialize with undefined or null values let initializeListener = true; if(this.#unserializedValue === undefined) initializeListener = false; if(this.#unserializedValue === null) initializeListener = false; if(initializeListener) listener(this.#unserializedValue, null); return () => this.unsubscribe(listener); // Return unsubscribe function } /** * Remove a listener function from notification list * @param {Function} listener - The listener to remove */ unsubscribe(listener) { this.#listeners.delete(listener); } /** * Notify all listeners of a value change with debouncing * @private */ #notify(newValue, oldValue) { clearTimeout(this.#debounceTimeout); this.#debounceTimeout = setTimeout(() => { this.#listeners.forEach(listener => listener(newValue, oldValue, this.#revision, this.#revisionUuid)); }, this.#debounce); } /** * Set a new value, incrementing the revision and notifying listeners */ set value(newUnserializedValue) { const oldSerializedValue = this.#serializedValue; const newSerializedValue = JSON.stringify(newUnserializedValue); // Compare serialized values to handle edge cases // WARN/TODO: serializer should sort keys to ensure comparison is stable const isUnchanged = oldSerializedValue === newSerializedValue; if(isUnchanged) return; // Early exit if no change // Increment revision and generate new UUID this.#revision = this.#revision + 1; this.#revisionUuid = generateTimestampedUUID(); // Update stored values this.#unserializedValue = newUnserializedValue; this.#serializedValue = newSerializedValue; // Notify subscribers with new and old values const oldUnserializedValue = this.#unserializedValue; this.#notify(newUnserializedValue, oldUnserializedValue); /* NOTE: Server synchronization is handled externally The user can implement this by subscribing to changes and propagating them to their synchronization system */ } /** * Get the current value * @returns {*} The current unserialized value */ get value() { return this.#unserializedValue; } /** * Apply a remote update with conflict resolution * @param {number} remoteRevision - The revision number from the remote source * @param {string} remoteRevisionId - The UUID of the remote revision * @param {*} newUnserializedRemoteValue - The new value from the remote source */ remote(remoteRevision, remoteRevisionId, newUnserializedRemoteValue) { // Validate input parameters if (!Number.isInteger(remoteRevision) || isNaN(remoteRevision)) { throw new Error("Remote revision must be an integer"); } if (remoteRevision < 1) { throw new Error("Remote revision must be 1 or greater"); } if (!remoteRevisionId || typeof remoteRevisionId !== 'string') { throw new Error("Remote revision ID must be a non-empty string"); } // Determine the type of update const isUpdate = remoteRevision > this.#revision; const isDuplicate = (remoteRevision === this.#revision) && (remoteRevisionId === this.#revisionUuid); const isConflict = remoteRevision === this.#revision && !isDuplicate; console.log({isUpdate, isDuplicate, isConflict}); if (isUpdate) { // Simple case: remote revision is newer const oldSerializedValue = this.#serializedValue; const newSerializedValue = JSON.stringify(newUnserializedRemoteValue); const isChanged = oldSerializedValue !== newSerializedValue; this.#revision = remoteRevision; this.#revisionUuid = remoteRevisionId; if (isChanged) { // Only notify if actual value has changed this.#unserializedValue = newUnserializedRemoteValue; this.#serializedValue = newSerializedValue; const oldUnserializedValue = this.#unserializedValue; this.#notify(newUnserializedRemoteValue, oldUnserializedValue); } } else if (isConflict) { // Conflict case: same revision, different UUIDs // Winner determined by comparing UUIDs alphanumerically const isWinner = remoteRevisionId > this.#revisionUuid; console.log({isWinner}, remoteRevisionId, this.#revisionUuid) if (isWinner) { // Take on remote revision and revision id this.#revision = remoteRevision; this.#revisionUuid = remoteRevisionId; // Update to remote value const newSerializedValue = JSON.stringify(newUnserializedRemoteValue); const oldSerializedValue = this.#serializedValue; const isChanged = oldSerializedValue !== newSerializedValue; console.log({isChanged}, oldSerializedValue, newSerializedValue, newUnserializedRemoteValue) this.#unserializedValue = newUnserializedRemoteValue; this.#serializedValue = newSerializedValue; if (isChanged) { const oldUnserializedValue = this.#unserializedValue; this.#notify(newUnserializedRemoteValue, oldUnserializedValue); } } } else if (isDuplicate) { // Duplicate case: we already have this exact revision // Do nothing, assuming an "at least once" delivery scenario } } /** * Get the current debounce timeout in milliseconds * @returns {number} Debounce timeout in milliseconds */ get debounce() { return this.#debounce; } /** * Set the debounce timeout for notifications * @param {number} ms - Debounce timeout in milliseconds */ set debounce(ms) { if (!Number.isInteger(ms) || ms < 0) { throw new Error("Debounce timeout must be a non-negative integer"); } this.#debounce = ms; } /** * Get the current revision number (read-only) * @returns {number} Current revision number */ get revision() { return this.#revision; } /** * Get the current revision UUID (read-only) * @returns {string} Current revision UUID */ get revisionUuid() { return this.#revisionUuid; } } /** * Generate a unique identifier combining timestamp and random values * @returns {string} A unique identifier string */ function generateTimestampedUUID() { const timestamp = Date.now().toString(36); const randomPart = Math.random().toString(36).substring(2, 10); return `${timestamp}-${randomPart}`; }