๐Ÿ“„ src/web3/session/sync.ts
D-OPEN SOVEREIGN

Documentation & Insights

Runs a subscription-graph sync for userId, applies any newly-delivered
patches to IndexedDB and the live in-memory ORM, and emits
WalletEvents.emitSubscriptionPatchesSynced as a UX-only completion signal โ€”
mirroring how restoreAccountSave emits WalletSaveRestoredEvent for the
user's own saves. Never throws; a subscription-sync failure must not break
account hydration.

Fetched patches are never discarded: the cursor only advances (cursorStore.set)
after they've been applied, so a patch is never marked delivered without
also being merged into local state.
Hydrates one address from chain and updates sessionStore. Replaces
registry.addAccount(wallet, tech, chainId, address, forceRefresh).
Cross-device sync for the caller's own saved patches: checks local state
against on-chain "writes" and, if the chain is ahead, decodes, persists to
IndexedDB, and merges the missing patches into the live ORM โ€” all
internally. Callers get only a completion boolean; WalletEvents.onSaveRestored
fires afterward as a UX-only "sync finished" signal (no patch payload to act on).
Meant to be called once at boot, non-blocking (fire-and-forget).
Replaces registry.restoreMySave(wallet, tech, chainId, account).
Read-only check for whether the caller has locally-authored patches not
yet pushed to BackupStorage via saveMyLevel() (mutations.ts). No wallet/
connector required โ€” safe to call just to drive a "you have unsaved
changes" UI hint.
Pure data fetcher for network-wide statistics.
1import { getReadClient } from '../client/registry';
2import type { WalletType } from '../client/types';
3import { WalletEvents } from '../events/wallet_events';
4import { getSubscriptionClient } from '../subscription/registry';
5import { cursorStore } from '../subscription/sync-cursor';
6import { patchToEvmPayload } from '../subscription/patch-codec';
7import { sessionStore } from './store';
8import { getItem, setItem } from './storage';
9import type { Web3SessionAccount } from './types';
10import type { AnyPatch } from '../../models/binary';
11import { deserializePatches, applyRestoredPatches, CURRENT_VERSION } from '@the_library/public/models';
12import { getAllPatchesByUsername, getNbPatches } from '../../models/indexdb/patch_storage';
13
14// Gates triggerSubscriptionSync to once per (chainId, userId) per app session.
15// syncAccount() runs it on every hydration path (boot, post-write refresh,
16// account discovery), and a full graph re-walk on every one of those within a
17// single page load is wasted work. The map is in-memory only โ€” it's cleared
18// on reload, and the next session still syncs (incrementally, via the
19// persisted cursor), so subscribed-library updates are never permanently missed.
20const _syncedThisSession = new Map<string, Promise<void>>();
21
22/**
23 * Runs a subscription-graph sync for userId, applies any newly-delivered
24 * patches to IndexedDB and the live in-memory ORM, and emits
25 * WalletEvents.emitSubscriptionPatchesSynced as a UX-only completion signal โ€”
26 * mirroring how restoreAccountSave emits WalletSaveRestoredEvent for the
27 * user's own saves. Never throws; a subscription-sync failure must not break
28 * account hydration.
29 *
30 * Fetched patches are never discarded: the cursor only advances (cursorStore.set)
31 * after they've been applied, so a patch is never marked delivered without
32 * also being merged into local state.
33 */
34async function triggerSubscriptionSync(
35  userId: number,
36  chainId: number,
37  environment: 'production' | 'staging'
38): Promise<void> {
39  const key = `${chainId}:${userId}`;
40  const inFlight = _syncedThisSession.get(key);
41  if (inFlight) return inFlight;
42
43  const run = (async () => {
44    const subscriptionClient = getSubscriptionClient(chainId, environment);
45    const existingCursor = await cursorStore.get(userId);
46    const result = existingCursor
47      ? await subscriptionClient.syncSince(existingCursor)
48      : await subscriptionClient.sync(userId);
49
50    let appliedCount = 0;
51    if (result.patches.length > 0) {
52      const client = getReadClient(chainId, environment);
53      await client.ready;
54
55      for (const patch of result.patches) {
56        try {
57          const username = await client.bouncerStorage.userIdToUsername(patch.userId);
58          if (!username) continue;
59          await deserializePatches(username, patchToEvmPayload(patch, CURRENT_VERSION));
60          const patches = await getAllPatchesByUsername(username);
61          await applyRestoredPatches(patches as unknown as AnyPatch[], username);
62          appliedCount += patch.toIndex - patch.fromIndex; // new ops delivered in this batch, not the full stored history
63        } catch (e) {
64          console.warn(`[session/sync] failed to apply subscription patch for userId ${patch.userId}`, e);
65        }
66      }
67    }
68
69    await cursorStore.set(userId, result.cursor);
70    WalletEvents.emitSubscriptionPatchesSynced({ rootUserId: userId, chainId, appliedCount, timestamp: Date.now() });
71  })();
72
73  _syncedThisSession.set(key, run);
74  try {
75    await run;
76  } catch (e) {
77    _syncedThisSession.delete(key); // let the next syncAccount() call retry on failure
78    console.warn(`[session/sync] subscription sync failed for userId ${userId}`, e);
79  }
80}
81
82/**
83 * Hydrates one address from chain and updates sessionStore. Replaces
84 * registry.addAccount(wallet, tech, chainId, address, forceRefresh).
85 */
86export async function syncAccount(
87  address: string,
88  chainId: number,
89  wallet: WalletType,
90  environment: 'production' | 'staging' = 'production',
91  forceRefresh = false
92): Promise<Web3SessionAccount> {
93  const existing = sessionStore
94    .getState()
95    .known.find((a) => a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId);
96
97  if (!forceRefresh && existing?.lastUpdated) {
98    // Cache hit โ€” skip the expensive on-chain rehydration below, but still let
99    // subscription sync run in the background (O6: syncAccount is the trigger
100    // on every path that calls it, including boot-time restoration from
101    // sessionStore.load(), not just first-time hydration). Not awaited so this
102    // fast path stays fast.
103    if (existing.hasAccount && existing.userId) {
104      void triggerSubscriptionSync(existing.userId, chainId, environment);
105    }
106    return existing;
107  }
108
109  const client = getReadClient(chainId, environment);
110  await client.ready;
111
112  const account: Web3SessionAccount = existing
113    ? { ...existing }
114    : {
115        account: address,
116        username: '',
117        hasAccount: false,
118        userId: 0,
119        lastPatchIndex: 0,
120        lastRestorationIndex: 0,
121        chainId,
122        wallet,
123      };
124
125  const hasAccount = await client.bouncerStorage.hasAccount(address);
126  account.hasAccount = hasAccount;
127
128  if (hasAccount) {
129    const [username, country, language, userId] = await client.bouncerStorage.userInfos(address);
130    account.username = username;
131    account.country = country;
132    account.language = language;
133    account.userId = userId;
134
135    const { total, donations, unspent } = await client.projectManagerStorage.balanceOfAddress(address);
136    account.projectBalance = { contributions: total, allocated: donations, remaining: unspent };
137
138    try {
139      account.level = await client.archivistStorage.userLevel(userId);
140    } catch {
141      account.level = 1;
142    }
143
144    try {
145      account.donationsPerProject = await client.projectManagerStorage.addressDonationsPerProject(address);
146    } catch {
147      account.donationsPerProject = [];
148    }
149
150    // O6 (ยง4.6/ยง15.8): syncAccount is the sole trigger for subscription-graph sync โ€”
151    // not a separately-called flow. Runs on every path that already calls syncAccount
152    // (post-write refresh, account discovery, boot), so external/other-session
153    // subscription changes are picked up opportunistically too.
154    await triggerSubscriptionSync(userId, chainId, environment);
155  }
156
157  account.lastUpdated = Date.now();
158  sessionStore.addKnown(account);
159  return account;
160}
161
162/**
163 * Cross-device sync for the caller's own saved patches: checks local state
164 * against on-chain "writes" and, if the chain is ahead, decodes, persists to
165 * IndexedDB, and merges the missing patches into the live ORM โ€” all
166 * internally. Callers get only a completion boolean; WalletEvents.onSaveRestored
167 * fires afterward as a UX-only "sync finished" signal (no patch payload to act on).
168 * Meant to be called once at boot, non-blocking (fire-and-forget).
169 * Replaces registry.restoreMySave(wallet, tech, chainId, account).
170 */
171export async function restoreAccountSave(
172  address: string,
173  chainId: number,
174  environment: 'production' | 'staging' = 'production'
175): Promise<boolean> {
176  const known = sessionStore
177    .getState()
178    .known.find((a) => a.account.toLowerCase() === address.toLowerCase() && a.chainId === chainId);
179
180  if (!known || !known.hasAccount) return false;
181
182  const client = getReadClient(chainId, environment);
183  await client.ready;
184
185  try {
186    const nbSavedIndex = await client.factory.nbWritesByUsername(known.username);
187
188    if (nbSavedIndex > known.lastRestorationIndex) {
189      const [compressedActions, indexes, stringIndexes] = await client.factory.loadUsernameSince(
190        known.username,
191        known.lastRestorationIndex
192      );
193
194      let appliedCount = 0;
195      if (compressedActions && compressedActions.length > 0) {
196        await deserializePatches('mine', {
197          version: CURRENT_VERSION,
198          patches: compressedActions,
199          textIndex: indexes.map(Number),
200          textData: stringIndexes,
201        });
202        const patches = await getAllPatchesByUsername('mine');
203        await applyRestoredPatches(patches as unknown as AnyPatch[], 'mine');
204        appliedCount = compressedActions.length; // new ops delivered in this batch, not the full stored history
205      }
206
207      sessionStore.setLastRestorationIndex(address, chainId, nbSavedIndex);
208      WalletEvents.emitSaveRestored({
209        wallet: known.wallet,
210        chainId,
211        account: address,
212        username: known.username,
213        newRestorationIndex: nbSavedIndex,
214        appliedCount,
215        timestamp: Date.now(),
216      });
217    }
218    return true;
219  } catch (e) {
220    console.error(`[session/sync] restoreAccountSave failed for ${address} on ${chainId}`, e);
221    return false;
222  }
223}
224
225/**
226 * Read-only check for whether the caller has locally-authored patches not
227 * yet pushed to BackupStorage via saveMyLevel() (mutations.ts). No wallet/
228 * connector required โ€” safe to call just to drive a "you have unsaved
229 * changes" UI hint.
230 */
231export async function hasUnsavedPatches(address: string, chainId: number): Promise<boolean> {
232  const { lastPatchIndex } = sessionStore.lastSyncCounters(address, chainId);
233  const nbPatches = await getNbPatches('mine');
234  return nbPatches > lastPatchIndex;
235}
236
237/**
238 * Pure data fetcher for network-wide statistics.
239 */
240export async function loadNetworkInfos(
241  chainId: number,
242  environment: 'production' | 'staging' = 'production',
243  forceRefresh = false
244) {
245  const key = `networkInfos_${chainId}${environment === 'staging' ? '_staging' : ''}`;
246  if (!forceRefresh) {
247    const cached = getItem(key);
248    if (cached) return cached;
249  }
250
251  const client = getReadClient(chainId, environment);
252  await client.ready;
253
254  const [locationStats, nbProjects, nbUsers] = await Promise.all([
255    client.bouncerStorage.getAllLocationCounts(),
256    client.projectManagerStorage.nbProjects(),
257    client.bouncerStorage.nbAccounts(),
258  ]);
259
260  const result = { nbUsers, nbLocations: locationStats.length, nbProjects, locationStats };
261  setItem(key, result);
262  return result;
263}
264