π src/web3/subscription/SubscriptionClient.ts
D-OPEN SOVEREIGN
Documentation & Insights
Orchestrates graph walk + patch fetch. Algorithm unchanged from the
original `web3_subscription_package.md` spec (Β§5-8); only the module
boundary and the patch source (on-chain BackupStorage, not Arweave) moved.Full walk + fetch from scratch.Incremental sync (spec Β§8, unchanged algorithm): multicall
getSubscriptionCount for every known node, deep re-walk only the nodes
whose count changed (O4: completeness over cost), then fetch only new
patches per node since the cursor.High-level API: load and apply library patches for a user by username.
Orchestrates: usernameβuserId lookup, sync, deserialize, and IndexedDB application.
Errors are caught and logged to console but not thrown (graceful degradation).1import type { EvmClient } from '../client/EvmClient';
2import { getEvmClient } from '../client/registry';
3import { multicallGetSubscriptionCounts } from './contract';
4import { walkGraph } from './graph-walk';
5import { fetchPatches } from './patch-resolver';
6import { patchToEvmPayload } from './patch-codec';
7import type { FetchOptions, SubscriptionGraph, SyncCursor, SyncOptions, SyncResult, WalkOptions } from './types';
8import type { AnyPatch } from '../../models/binary';
9import { deserializePatches, applyRestoredPatches, CURRENT_VERSION } from '@the_library/public/models';
10import { getAllPatchesByUsername } from '../../models/indexdb/patch_storage';
11
12export interface SubscriptionClientConfig {
13 chainId: number;
14 environment?: 'production' | 'staging';
15 maxWalkNodes?: number;
16 maxDepth?: number;
17}
18
19/**
20 * Orchestrates graph walk + patch fetch. Algorithm unchanged from the
21 * original `web3_subscription_package.md` spec (Β§5-8); only the module
22 * boundary and the patch source (on-chain BackupStorage, not Arweave) moved.
23 */
24export class SubscriptionClient {
25 private readonly client: EvmClient;
26 private readonly defaultMaxWalkNodes?: number;
27 private readonly defaultMaxDepth?: number;
28
29 constructor(config: SubscriptionClientConfig) {
30 this.client = getEvmClient(config.chainId, config.environment ?? 'production');
31 this.defaultMaxWalkNodes = config.maxWalkNodes;
32 this.defaultMaxDepth = config.maxDepth;
33 }
34
35 async walkGraph(rootUserId: number, opts?: WalkOptions): Promise<SubscriptionGraph> {
36 await this.client.ready;
37 return walkGraph(this.client, rootUserId, {
38 maxNodes: opts?.maxNodes ?? this.defaultMaxWalkNodes,
39 maxDepth: opts?.maxDepth ?? this.defaultMaxDepth,
40 });
41 }
42
43 async fetchPatches(graph: SubscriptionGraph, cursor?: Map<number, number>, opts?: FetchOptions) {
44 await this.client.ready;
45 return fetchPatches(this.client, graph, cursor, opts);
46 }
47
48 /** Full walk + fetch from scratch. */
49 async sync(rootUserId: number, opts?: SyncOptions): Promise<SyncResult> {
50 const graph = await this.walkGraph(rootUserId, opts);
51 const patches = await this.fetchPatches(graph, undefined, opts);
52
53 const countSnapshot = new Map<number, number>();
54 for (const [userId, node] of graph.nodes) {
55 countSnapshot.set(userId, node.subscriptions.length);
56 }
57
58 const patchIndex = new Map<number, number>();
59 for (const patch of patches) {
60 patchIndex.set(patch.userId, Math.max(patchIndex.get(patch.userId) ?? 0, patch.toIndex));
61 }
62 for (const userId of graph.nodes.keys()) {
63 if (!patchIndex.has(userId)) patchIndex.set(userId, 0);
64 }
65
66 return {
67 patches,
68 cursor: { rootUserId, knownNodes: graph.nodes, countSnapshot, patchIndex },
69 truncated: graph.truncated,
70 };
71 }
72
73 /**
74 * Incremental sync (spec Β§8, unchanged algorithm): multicall
75 * getSubscriptionCount for every known node, deep re-walk only the nodes
76 * whose count changed (O4: completeness over cost), then fetch only new
77 * patches per node since the cursor.
78 */
79 async syncSince(cursor: SyncCursor, opts?: SyncOptions): Promise<SyncResult> {
80 await this.client.ready;
81
82 const knownUserIds = Array.from(cursor.knownNodes.keys());
83 const counts = await multicallGetSubscriptionCounts(this.client, knownUserIds);
84 const changed = knownUserIds.filter((userId) => counts.get(userId) !== cursor.countSnapshot.get(userId));
85
86 const knownNodes = new Map(cursor.knownNodes);
87 const countSnapshot = new Map(counts.size ? counts : cursor.countSnapshot);
88 let truncated = false;
89
90 for (const userId of changed) {
91 const subs = await this.client.subscriptionManager.getSubscriptions(userId);
92 knownNodes.set(userId, { userId, subscriptions: subs });
93 countSnapshot.set(userId, subs.length);
94
95 for (const sub of subs) {
96 if (knownNodes.has(sub)) continue;
97 const subGraph = await this.walkGraph(sub, opts);
98 for (const [id, node] of subGraph.nodes) {
99 if (!knownNodes.has(id)) {
100 knownNodes.set(id, node);
101 countSnapshot.set(id, node.subscriptions.length);
102 }
103 }
104 truncated = truncated || subGraph.truncated;
105 }
106 }
107
108 // rootUserId is always in the graph, even if nobody subscribes to it (O5).
109 if (!knownNodes.has(cursor.rootUserId)) {
110 const subs = await this.client.subscriptionManager.getSubscriptions(cursor.rootUserId);
111 knownNodes.set(cursor.rootUserId, { userId: cursor.rootUserId, subscriptions: subs });
112 countSnapshot.set(cursor.rootUserId, subs.length);
113 }
114
115 const graph: SubscriptionGraph = { rootUserId: cursor.rootUserId, nodes: knownNodes, truncated };
116 const patches = await this.fetchPatches(graph, cursor.patchIndex, opts);
117
118 const patchIndex = new Map(cursor.patchIndex);
119 for (const patch of patches) {
120 patchIndex.set(patch.userId, Math.max(patchIndex.get(patch.userId) ?? 0, patch.toIndex));
121 }
122 for (const userId of knownNodes.keys()) {
123 if (!patchIndex.has(userId)) patchIndex.set(userId, 0);
124 }
125
126 return {
127 patches,
128 cursor: { rootUserId: cursor.rootUserId, knownNodes, countSnapshot, patchIndex },
129 truncated,
130 };
131 }
132
133 async getSubscriptions(userId: number): Promise<number[]> {
134 await this.client.ready;
135 return this.client.subscriptionManager.getSubscriptions(userId);
136 }
137
138 async getScore(userId: number): Promise<bigint> {
139 await this.client.ready;
140 return this.client.subscriptionManager.scoreOf(userId);
141 }
142
143 /**
144 * High-level API: load and apply library patches for a user by username.
145 * Orchestrates: usernameβuserId lookup, sync, deserialize, and IndexedDB application.
146 * Errors are caught and logged to console but not thrown (graceful degradation).
147 */
148 async loadLibraryByUsername(username: string): Promise<void> {
149 try {
150 await this.client.ready;
151 console.log(`[SubscriptionClient] Starting library load for @${username}...`);
152
153 // 1. Convert username to userId
154 console.log(`[SubscriptionClient] Looking up userId for @${username}...`);
155 const userId = await this.client.bouncerStorage.usernameToUserId(username);
156 if (!userId) {
157 console.error(`[SubscriptionClient] Username @${username} not found on-chain`);
158 return;
159 }
160 console.log(`[SubscriptionClient] Resolved @${username} β userId ${userId}`);
161
162 // 2. Sync patches from subscription graph
163 console.log(`[SubscriptionClient] Syncing patches for userId ${userId}...`);
164 const result = await this.sync(userId);
165 console.log(`[SubscriptionClient] Fetched ${result.patches.length} patch(es), truncated: ${result.truncated}`);
166
167 // 3. Deserialize into IndexedDB, then merge the full stored history into the live ORM.
168 // `result.patches` covers the whole subscription graph, not just @username β each
169 // patch must be attributed to its own author (patch.userId), same as session/sync.ts.
170 if (result.patches.length > 0) {
171 console.log(`[SubscriptionClient] Applying patches for ${result.patches.length} subscription graph patch(es)...`);
172
173 for (const patch of result.patches) {
174 try {
175 const patchUsername = await this.client.bouncerStorage.userIdToUsername(patch.userId);
176 if (!patchUsername) {
177 console.warn(`[SubscriptionClient] No username found for userId ${patch.userId}, skipping patch`);
178 continue;
179 }
180 console.log(`[SubscriptionClient] Applying patch for @${patchUsername} (userId ${patch.userId}, index ${patch.fromIndex}β${patch.toIndex})...`);
181 await deserializePatches(patchUsername, patchToEvmPayload(patch, CURRENT_VERSION));
182 const patches = await getAllPatchesByUsername(patchUsername);
183 await applyRestoredPatches(patches as unknown as AnyPatch[], patchUsername);
184 } catch (patchErr: any) {
185 console.error(`[SubscriptionClient] Failed to apply patch for userId ${patch.userId}:`, patchErr?.message || patchErr);
186 }
187 }
188 }
189
190 console.log(`[SubscriptionClient] Successfully loaded library for @${username}`);
191 } catch (err: any) {
192 console.error(`[SubscriptionClient] Failed to load library for @${username}:`, err?.message || err);
193 }
194 }
195}
196