๐ 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