πŸ“„ 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