9 directories, 58 files

tinai

Home / tinai
import { showNotification } from './util/notification-stack.js';
import { get_auth_storage_key } from './util/auth-storage.js';
import { customChoices } from './util/multi-choice-dialog.js';
import Migrations from './util/migrations.js';

const SYNC_DEBOUNCE_MS = 10000;
const CURSOR_ABSENT_MS = 5000;
const LAST_SYNC_KEY = 'LAST_SYNC';
const SYNC_STATE_KEY = 'SYNC_STATE';
const SYNC_CURSOR_KEY = 'SYNC_CURSOR';

/**
 * Server-side conversation sync. Every save of a conversation whose SYNCED
 * flag is set queues it for upload, so enabling sync is just flipping that
 * conversation setting. Runs are requested by the trigger funnel (conversation
 * save, focus, visibility, online, cursor reappearing after an absence,
 * content panel shown, conversation switch, app start): a request starts a run
 * immediately unless one is in flight or the last run started within the
 * debounce window, in which case all requests in that window consolidate into
 * one run at the window's end (or right after the in-flight run). Each run
 * first retries server deletions requested by this device but not yet
 * confirmed, then pulls server changes and pushes the queued uploads, and the
 * cursor only advances when the whole run succeeds. Uploads are silent; while a
 * conversation syncs in either direction its sync icons are dimmed, and
 * downloads report progress as "Synchronizing (done/total)" through the bell
 * notification system. The
 * last sync timestamp, the per-conversation sync records, and the server
 * cursor are kept in localStorage under the active user's session prefix.
 */
class Sync {

	#app;
	#inflight = false;
	#pending = false;
	#last_run_at = 0;
	#trailing_timer = null;
	#push_queue = new Set();
	#suppress_save_hook = false;
	#deleting = new Set();
	#last_cursor_seen = Date.now();
	#ran_post_sync_migrations = false;

	constructor(app) {
		this.#app = app;
		this.#app.storage.on_conversation_saved = (guid) => this.#on_conversation_saved(guid);
		update_last_sync_display();
		window.addEventListener('focus', () => this.request());
		document.addEventListener('visibilitychange', () => {
			if (!document.hidden) {
				this.request();
			}
		});
		window.addEventListener('online', () => this.request());
		window.addEventListener('mousemove', () => {
			const now = Date.now();
			if (now - this.#last_cursor_seen > CURSOR_ABSENT_MS) {
				this.request();
			}
			this.#last_cursor_seen = now;
		});
	}

	/**
	 * Requests a sync run. Starts one immediately unless a run is in flight or
	 * the last run started within the debounce window; in that case one
	 * consolidated run is queued for the end of the window, or right after the
	 * in-flight run finishes.
	 */
	request() {
		if (this.#inflight) {
			this.#pending = true;
			return;
		}
		const elapsed = Date.now() - this.#last_run_at;
		if (elapsed >= SYNC_DEBOUNCE_MS) {
			void this.#run();
			return;
		}
		if (!this.#trailing_timer) {
			this.#trailing_timer = setTimeout(() => {
				this.#trailing_timer = null;
				void this.#run();
			}, SYNC_DEBOUNCE_MS - elapsed);
		}
	}

	/**
	 * Turns off server sync for a conversation: removes the server copy and
	 * the local sync record. The conversation's SYNCED flag is updated by the caller.
	 * @param {string} guid - Conversation guid.
	 * @returns {Promise<void>} Resolves after the server request settles.
	 */
	disable_conversation(guid) {
		const state = this.#load_json(SYNC_STATE_KEY, {});
		delete state[guid];
		this.#save_json(SYNC_STATE_KEY, state);
		this.#push_queue.delete(guid);
		return this.#disable_on_server(guid).then(() => {});
	}

	/**
	 * Removes the server copy after a conversation was deleted locally and
	 * requests a sync run so the deletion settles. The guid stays flagged
	 * until the server confirms, so in-flight runs skip pulling it instead of
	 * showing the server-deleted dialog: this device already requested the
	 * deletion. A failed request is retried by the next run.
	 * @param {string} guid - Deleted conversation guid.
	 * @param {boolean} synced - Whether the conversation was synced.
	 */
	conversation_deleted(guid, synced) {
		const state = this.#load_json(SYNC_STATE_KEY, {});
		if (state[guid]) {
			delete state[guid];
			this.#save_json(SYNC_STATE_KEY, state);
		}
		this.#push_queue.delete(guid);
		if (synced) {
			void this.#disable_on_server(guid);
			this.request();
		}
	}

	/**
	 * Removes one guid's server copy and guards it against pulls until the
	 * server confirms the deletion.
	 * @param {string} guid - Conversation guid.
	 * @returns {Promise<boolean>} Whether the server confirmed the deletion.
	 * @private
	 */
	#disable_on_server(guid) {
		this.#deleting.add(guid);
		return this.#app.api.request({ action: 'disable', guid }, 'sync')
			.then(() => {
				this.#deleting.delete(guid);
				return true;
			})
			.catch(() => false);
	}

	/**
	 * Queues a just-saved conversation for upload when it is synced and
	 * differs from the last synced copy.
	 * @param {string} guid - Saved conversation guid.
	 * @private
	 */
	#on_conversation_saved(guid) {
		if (this.#suppress_save_hook) {
			return;
		}
		const state = this.#load_json(SYNC_STATE_KEY, {});
		if (!this.#needs_push(state, guid)) {
			return;
		}
		this.#push_queue.add(guid);
		this.request();
	}

	/**
	 * Returns whether a conversation is synced and differs from its last
	 * synced copy.
	 * @param {Object} state - Sync state map.
	 * @param {string} guid - Conversation guid.
	 * @returns {boolean}
	 * @private
	 */
	#needs_push(state, guid) {
		const storage = this.#app.storage;
		const conversation = storage.get_conversation(guid);
		if (!conversation?.[storage.KEY_CONVERSATION_SYNCED]) {
			return false;
		}
		return !state[guid] || state[guid].hash !== this.#hash_conversation(conversation);
	}

	/**
	 * Executes one sync run: pull server changes, then push queued uploads.
	 * Requests that arrived while it ran are re-requested afterwards, which
	 * either runs immediately or lands on the window's end.
	 * @private
	 */
	async #run() {
		if (this.#inflight) {
			this.#pending = true;
			return;
		}
		this.#inflight = true;
		this.#last_run_at = Date.now();
		try {
			await this.#sync_now();
		} finally {
			this.#inflight = false;
			if (this.#pending) {
				this.#pending = false;
				this.request();
			}
		}
	}

	/**
	 * Runs one sync pass and reports progress per processed item. The cursor
	 * only advances when the whole pass succeeds, so failed items are retried
	 * on the next run.
	 * @private
	 */
	async #sync_now() {
		const state = this.#load_json(SYNC_STATE_KEY, {});

		for (const guid of [...this.#deleting]) {
			try {
				const removed = await this.#disable_on_server(guid);
				if (!removed) {
					this.#notify_failure(new Error('Could not delete conversation on server'));
					return;
				}
			} catch (e) {
				this.#notify_failure(e);
				return;
			}
		}

		let changes;
		let cursor;
		try {
			const response = await this.#call({ action: 'changes', since: this.#load_json(SYNC_CURSOR_KEY, 0) });
			changes = response.changes || [];
			cursor = response.time;
		} catch (e) {
			this.#notify_failure(e);
			return;
		}

		const actionable = changes.filter(change => state[change.guid]?.synced_at !== change.updated_at);
		const total = actionable.length;
		if (total === 0 && this.#push_queue.size === 0) {
			this.#save_json(SYNC_CURSOR_KEY, cursor);
			this.#touch_last_sync();
			await this.#migrate_after_sync();
			return;
		}

		let done = 0;
		for (const change of actionable) {
			if (this.#deleting.has(change.guid)) {
				continue;
			}
			this.#set_syncing(change.guid, true);
			try {
				await this.#pull_conversation(state, change.guid, change.updated_at);
			} catch (e) {
				this.#set_syncing(change.guid, false);
				this.#notify_failure(e);
				return;
			}
			this.#set_syncing(change.guid, false);
			done++;
			showNotification(`Synchronizing (${done}/${total})`);
		}
		for (const guid of [...this.#push_queue]) {
			this.#push_queue.delete(guid);
			if (this.#needs_push(state, guid)) {
				this.#set_syncing(guid, true);
				try {
					await this.#push_conversation(state, guid);
				} catch (e) {
					this.#push_queue.add(guid);
					this.#set_syncing(guid, false);
					this.#notify_failure(e);
					return;
				}
				this.#set_syncing(guid, false);
			}
		}

		this.#save_json(SYNC_CURSOR_KEY, cursor);
		this.#touch_last_sync();
		await this.#migrate_after_sync();
	}

	/**
	 * Re-runs all local storage migrations once per session, after the first
	 * successful sync pass, so conversations pulled in from the server are
	 * migrated too. Forced because the load-time pass already recorded the
	 * migrations as applied.
	 * @private
	 */
	async #migrate_after_sync() {
		if (this.#ran_post_sync_migrations) return;
		this.#ran_post_sync_migrations = true;
		await new Migrations(this.#app.storage).run(true);
	}

	/**
	 * Dims or restores the sync icons of one conversation while it syncs: the
	 * icon in the content list item, and the one in the header when that
	 * conversation is currently open.
	 * @param {string} guid - Conversation being synced.
	 * @param {boolean} syncing - Whether the sync is in progress.
	 * @private
	 */
	#set_syncing(guid, syncing) {
		document.querySelectorAll(`#content-${CSS.escape(guid)} .span-title-sync, #scratchpad-item-${CSS.escape(guid)} .span-title-sync`).forEach(el => {
			el.classList.toggle('is-syncing', syncing);
		});
		const selected_guid = this.#app.storage.get_app_config()[this.#app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (selected_guid === guid) {
			document.querySelectorAll('#id-div-response-title .span-title-sync').forEach(el => {
				el.classList.toggle('is-syncing', syncing);
			});
		}
	}

	/**
	 * Uploads a conversation, resolving conflict and unsynced-removal dialogs
	 * along the way. Transport errors throw; dialogs settle the item.
	 * @param {Object} state - Mutable sync state map.
	 * @param {string} guid - Conversation guid.
	 * @private
	 */
	async #push_conversation(state, guid) {
		const storage = this.#app.storage;
		const conversation = storage.get_conversation(guid);

		let response = await this.#call({
			action: 'push',
			guid,
			conversation,
			known_updated_at: state[guid]?.synced_at || 0
		});

		if (response.conflict) {
			const choice = await this.#ask_conflict(guid, 'push');
			if (choice === 'mine') {
				response = await this.#call({ action: 'push', guid, conversation, force: true });
			} else if (choice === 'server' || choice === 'both') {
				if (choice === 'both') {
					this.#archive_local_copy(guid);
				}
				const server = await this.#call({ action: 'get', guid });
				this.#write_server_conversation(state, guid, server.conversation, server.updated_at);
				return;
			} else {
				return;
			}
		}

		if (response.exists === false) {
			await this.#resolve_server_deleted(state, guid);
			return;
		}

		this.#record_synced(state, guid, response.updated_at, conversation);
	}

	/**
	 * Downloads one server change, asking on local conflicts.
	 * @param {Object} state - Mutable sync state map.
	 * @param {string} guid - Conversation guid.
	 * @param {number} updatedAt - Server updated_at for this change.
	 * @private
	 */
	async #pull_conversation(state, guid, updatedAt) {
		const storage = this.#app.storage;
		const known = state[guid];
		if (known && known.synced_at === updatedAt) {
			return;
		}

		const has_local = localStorage.getItem(storage.KEY_APP_CONVERSATION_PREFIX + guid) !== null;
		if (has_local && (!known || known.hash !== this.#hash_conversation(storage.get_conversation(guid)))) {
			const choice = await this.#ask_conflict(guid, 'pull');
			if (choice === 'mine') {
				const response = await this.#call({
					action: 'push', guid, conversation: storage.get_conversation(guid), force: true
				});
				this.#record_synced(state, guid, response.updated_at, storage.get_conversation(guid));
				return;
			}
			if (choice === 'both') {
				this.#archive_local_copy(guid);
			}
			if (choice === null) {
				return;
			}
		}

		const response = await this.#call({ action: 'get', guid });
		if (response.exists === false) {
			if (known) {
				await this.#resolve_server_deleted(state, guid);
			}
			return;
		}
		this.#write_server_conversation(state, guid, response.conversation, response.updated_at);
	}

	/**
	 * Asks which version wins after a sync conflict.
	 * @param {string} guid - Conversation guid.
	 * @param {('push'|'pull')} direction - Whether an upload or download hit the conflict.
	 * @returns {Promise<string|null>} 'mine', 'server', 'both', or null to skip.
	 * @private
	 */
	async #ask_conflict(guid, direction) {
		const storage = this.#app.storage;
		const conversation = storage.get_conversation(guid);
		const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
		const text = direction === 'push'
			? 'The server copy has changed since your last sync. Which version should be kept?'
			: 'A newer version exists on the server, and this copy has unsynced changes. Which version should be kept?';
		const choice = await customChoices(`Sync conflict: ${title}`, text, [
			{ label: 'Use my version', value: 'mine' },
			{ label: 'Use server version', value: 'server' },
			{ label: 'Keep both', value: 'both' },
			{ label: 'Skip', value: 'skip' }
		]);
		return choice === 'skip' ? null : choice;
	}

	/**
	 * Stores a copy of the local conversation under a new guid so the server
	 * version can take over the original one.
	 * @param {string} guid - Original conversation guid.
	 * @private
	 */
	#archive_local_copy(guid) {
		const storage = this.#app.storage;
		const conversation = storage.get_conversation(guid);
		const meta = storage.get_app_index().find(item => item[storage.KEY_INDEX_GUID] === guid);
		const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
		const copy = {
			...conversation,
			[storage.KEY_CONVERSATION_TITLE]: `${title} (archived)`,
			[storage.KEY_CONVERSATION_ARCHIVED]: true,
			[storage.KEY_CONVERSATION_SYNCED]: false
		};
		const new_guid = storage.update_app_index('', false, meta?.[storage.KEY_CONVERSATION_TYPE]);
		this.#suppress_save_hook = true;
		storage.save_conversation(new_guid, copy);
		this.#suppress_save_hook = false;
	}

	/**
	 * Asks what happens to a local copy whose server copy was deleted
	 * elsewhere, so this device holds the only remaining version.
	 * @param {Object} state - Mutable sync state map.
	 * @param {string} guid - Conversation guid.
	 * @private
	 */
	async #resolve_server_deleted(state, guid) {
		const storage = this.#app.storage;
		const conversation = storage.get_conversation(guid);
		const title = conversation?.[storage.KEY_CONVERSATION_TITLE] || 'Conversation';
		const choice = await customChoices(`Deleted from server: ${title}`, `"${title}" has been deleted from the server. The copy on this device is now the only remaining version. What should happen with it?`, [
			{ label: 'Re-enable sync', value: 'enable' },
			{ label: 'Delete this copy', value: 'delete' },
			{ label: 'Make independent', value: 'independent' }
		]);

		if (choice === 'enable') {
			const response = await this.#call({ action: 'push', guid, conversation });
			this.#record_synced(state, guid, response.updated_at, conversation);
			return;
		}
		if (choice === 'delete') {
			this.#suppress_save_hook = true;
			if (storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === guid) {
				this.#app.close_conversation();
			}
			storage.index_delete(guid);
			this.#suppress_save_hook = false;
			this.#app.share?.conversation_deleted(guid);
			return;
		}
		if (choice === 'independent') {
			storage.update_conversation_field(guid, storage.KEY_CONVERSATION_SYNCED, false);
			delete state[guid];
			this.#save_json(SYNC_STATE_KEY, state);
			this.#app.content_list?.on_app_index_updated?.();
		}
	}

	/**
	 * Writes a server conversation into the local store, creating the index
	 * entry for unknown guids, and records its sync state.
	 * @param {Object} state - Mutable sync state map.
	 * @param {string} guid - Conversation guid.
	 * @param {Object} conversation - Server conversation data.
	 * @param {number} updatedAt - Server updated_at for this version.
	 * @private
	 */
	#write_server_conversation(state, guid, conversation, updatedAt) {
		const storage = this.#app.storage;
		this.#suppress_save_hook = true;
		storage.save_conversation(guid, conversation);
		this.#suppress_save_hook = false;
		storage.update_app_index(guid, false, conversation?.[storage.KEY_CONVERSATION_TYPE]);
		this.#record_synced(state, guid, updatedAt, storage.get_conversation(guid));
		if (storage.get_app_config()[storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === guid) {
			this.#app.chat_renderer.refresh_after_sync_down(guid);
		}
	}

	/**
	 * Records a conversation's synced state: the server timestamp it matched
	 * and the local copy's hash.
	 * @param {Object} state - Mutable sync state map.
	 * @param {string} guid - Conversation guid.
	 * @param {number} updatedAt - The server updated_at now synced.
	 * @param {Object} conversation - The synced conversation data.
	 * @private
	 */
	#record_synced(state, guid, updatedAt, conversation) {
		state[guid] = { synced_at: updatedAt, hash: this.#hash_conversation(conversation) };
		this.#save_json(SYNC_STATE_KEY, state);
	}

	#hash_conversation(conversation) {
		return this.#hash_string(JSON.stringify(conversation));
	}

	/**
	 * Computes a numeric hash of a string (cyrb53).
	 * @param {string} text
	 * @returns {number}
	 */
	#hash_string(text) {
		let h1 = 0xdeadbeef;
		let h2 = 0x41c6ce57;
		for (let i = 0; i < text.length; i++) {
			const ch = text.charCodeAt(i);
			h1 = Math.imul(h1 ^ ch, 2654435761);
			h2 = Math.imul(h2 ^ ch, 1597334677);
		}
		h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
		h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
		return 4294967296 * (h2 >>> 0) + (h1 >>> 0);
	}

	/**
	 * Sends one sync API request, rejecting on transport or API errors.
	 * @param {Object} payload - Request payload including the action.
	 * @returns {Promise<Object>} The response object.
	 * @private
	 */
	#call(payload) {
		return this.#app.api.request(payload, 'sync').then(response => {
			if (!response || response.success !== true) {
				throw new Error(response?.error || 'Sync request failed');
			}
			return response;
		});
	}

	#notify_failure(error) {
		console.error('Sync failed:', error);
		showNotification('Sync failed');
	}

	#touch_last_sync() {
		localStorage.setItem(get_auth_storage_key(LAST_SYNC_KEY), String(Date.now()));
		update_last_sync_display();
	}

	#load_json(key, fallback) {
		try {
			const parsed = JSON.parse(localStorage.getItem(get_auth_storage_key(key)));
			return parsed ?? fallback;
		} catch {
			return fallback;
		}
	}

	#save_json(key, value) {
		localStorage.setItem(get_auth_storage_key(key), JSON.stringify(value));
	}
}

/**
 * Shows the time of the last completed sync run in the footer, or a dash
 * when this session has not synchronized yet.
 */
export function update_last_sync_display() {
	const el = document.getElementById('id-span-footer-sync-time');
	if (!el) return;
	const stamp = parseInt(localStorage.getItem(get_auth_storage_key(LAST_SYNC_KEY)), 10);
	el.textContent = Number.isFinite(stamp) && stamp > 0 ? new Date(stamp).toLocaleTimeString('en-US') : '—';
}

export default Sync;
🌐
sync.js ×
Type: Web, text/plain
19.02 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File