1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { invoke } from '@tauri-apps/api/core';
6// Single FIFO queue for every backend write, granular or full.
7// Serializing all writes through one promise chain prevents the race where
8// a granular edit on a freshly-added item would reach the backend before
9// the full sync that created it.
10let queue: Promise<void> = Promise.resolve();
11let invokeBackend: typeof invoke = invoke;
13const GRANULAR_FLUSH_INTERVAL_MS = 16;
15interface GranularUpdate {
21export interface GranularSyncFailure {
28 promise: Promise<void>;
30 reject: (reason?: unknown) => void;
33type GranularSyncFailureHandler = (
34 failure: GranularSyncFailure
35) => Promise<void> | void;
36type SyncWarningsHandler = (warnings: string[]) => Promise<void> | void;
38// Latest pending granular payload per backend object. Older snapshots for the
39// same item are obsolete because granular syncs send full item state, not diffs.
40const pendingGranularUpdates = new Map<string, GranularUpdate>();
41let granularFlushTimer: ReturnType<typeof setTimeout> | null = null;
42let scheduledGranularFlush: Deferred | null = null;
44// Reference to a full sync that has been enqueued but has not yet started
45// executing. While this is set, any further enqueueFullSync() calls are
46// coalesced into it — the snapshot is captured when the task runs, so
47// later changes are automatically included. It is cleared at the start of
48// the task body so subsequent enqueues create a fresh task.
49let pendingFullSync: Promise<void> | null = null;
50let granularSyncEpoch = 0;
51let granularSyncFailureHandler: GranularSyncFailureHandler | null = null;
52let syncWarningsHandler: SyncWarningsHandler | null = null;
54function createDeferred(): Deferred {
55 let resolve!: () => void;
56 let reject!: (reason?: unknown) => void;
57 const promise = new Promise<void>((res, rej) => {
61 return { promise, resolve, reject };
64function getGranularUpdateKey(itemType: string, itemId: string): string {
65 return `${itemType}:${itemId}`;
68function discardBufferedGranularSync(replacement?: Promise<void>): void {
69 if (granularFlushTimer) {
70 clearTimeout(granularFlushTimer);
71 granularFlushTimer = null;
74 if (pendingGranularUpdates.size > 0) {
75 pendingGranularUpdates.clear();
78 if (scheduledGranularFlush) {
79 const deferred = scheduledGranularFlush;
81 void replacement.then(
82 () => deferred.resolve(),
83 (error) => deferred.reject(error)
88 scheduledGranularFlush = null;
92function invalidatePendingGranularSyncs(): void {
93 granularSyncEpoch += 1;
94 discardBufferedGranularSync();
97async function handleGranularSyncFailure(
98 update: GranularUpdate,
102 `Granular sync failed (${update.itemType} ${update.itemId}):`,
106 invalidatePendingGranularSyncs();
108 if (granularSyncFailureHandler) {
109 await granularSyncFailureHandler({
110 itemType: update.itemType,
111 itemId: update.itemId,
117async function handleSyncWarnings(warnings: unknown): Promise<void> {
119 !Array.isArray(warnings) ||
120 warnings.length === 0 ||
125 await syncWarningsHandler(
127 (warning): warning is string => typeof warning === 'string'
132function scheduleGranularFlush(): Promise<void> {
133 if (!scheduledGranularFlush) {
134 scheduledGranularFlush = createDeferred();
137 if (granularFlushTimer) {
138 return scheduledGranularFlush.promise;
141 granularFlushTimer = setTimeout(() => {
142 granularFlushTimer = null;
144 const updates = [...pendingGranularUpdates.values()];
145 pendingGranularUpdates.clear();
147 const flushDeferred = scheduledGranularFlush;
148 scheduledGranularFlush = null;
150 if (updates.length === 0) {
151 flushDeferred?.resolve();
155 const taskEpoch = granularSyncEpoch;
156 const task = queue.then(async () => {
157 if (taskEpoch !== granularSyncEpoch) {
161 for (const update of updates) {
163 const warnings = await invokeBackend<string[]>(
164 'update_item_from_json',
166 itemType: update.itemType,
167 itemId: update.itemId,
171 await handleSyncWarnings(warnings);
173 await handleGranularSyncFailure(update, e);
179 queue = task.catch(() => undefined);
181 () => flushDeferred?.resolve(),
182 (error) => flushDeferred?.reject(error)
184 }, GRANULAR_FLUSH_INTERVAL_MS);
186 return scheduledGranularFlush.promise;
189export function setSyncQueueInvokerForTests(testInvoke?: typeof invoke): void {
190 invokeBackend = testInvoke ?? invoke;
193/** Enqueue a granular item update behind any in-flight work. */
194export function enqueueGranularSync(
199 pendingGranularUpdates.set(getGranularUpdateKey(itemType, itemId), {
204 return scheduleGranularFlush();
207export function registerGranularSyncFailureHandler(
208 handler: GranularSyncFailureHandler | null
210 granularSyncFailureHandler = handler;
213export function registerSyncWarningsHandler(
214 handler: SyncWarningsHandler | null
216 syncWarningsHandler = handler;
220 * Enqueue a full scenario snapshot. Coalesces with any pending full sync.
221 * `buildJson` MUST read live state at call time.
223export function enqueueFullSync(buildSnapshot: () => string): Promise<void> {
224 // A pending full snapshot already contains any granular edits that have not
225 // yet been appended to the FIFO queue, so drop those buffered writes.
226 if (pendingFullSync) {
227 discardBufferedGranularSync(pendingFullSync);
228 return pendingFullSync;
231 const task = queue.then(async () => {
232 // Clear before snapshot so any enqueues from this point create a new task.
233 pendingFullSync = null;
234 const json = buildSnapshot();
236 const warnings = await invokeBackend<string[]>(
237 'update_scenario_from_json',
240 await handleSyncWarnings(warnings);
242 console.error('Full sync failed:', e);
245 pendingFullSync = task;
246 discardBufferedGranularSync(task);
247 queue = task.catch(() => undefined);
251/** Resolves once every currently-queued sync task has settled. */
252export function waitForSyncIdle(): Promise<void> {
255 scheduledGranularFlush?.promise ?? Promise.resolve(),
256 ]).then(() => undefined);
259export function resetSyncQueueForTests(): void {
260 if (granularFlushTimer) {
261 clearTimeout(granularFlushTimer);
262 granularFlushTimer = null;
265 pendingGranularUpdates.clear();
266 scheduledGranularFlush = null;
267 pendingFullSync = null;
268 queue = Promise.resolve();
269 invokeBackend = invoke;
270 granularSyncEpoch = 0;
271 granularSyncFailureHandler = null;
272 syncWarningsHandler = null;