@effectstream/sync 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +29 -0
  2. package/src/mod.ts +3 -0
  3. package/src/sync-protocols/avail/AvailClient.ts +120 -0
  4. package/src/sync-protocols/avail/fetcher.ts +174 -0
  5. package/src/sync-protocols/avail/state.ts +134 -0
  6. package/src/sync-protocols/avail/types.ts +92 -0
  7. package/src/sync-protocols/base/fetcher.ts +43 -0
  8. package/src/sync-protocols/base/page.ts +89 -0
  9. package/src/sync-protocols/base/primitive.ts +24 -0
  10. package/src/sync-protocols/base/state.ts +143 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +549 -0
  12. package/src/sync-protocols/bitcoin/state.ts +144 -0
  13. package/src/sync-protocols/bitcoin/types.ts +92 -0
  14. package/src/sync-protocols/celestia/CelestiaClient.ts +139 -0
  15. package/src/sync-protocols/celestia/fetcher.ts +165 -0
  16. package/src/sync-protocols/celestia/state.ts +131 -0
  17. package/src/sync-protocols/celestia/types.ts +46 -0
  18. package/src/sync-protocols/common/page-helpers.ts +65 -0
  19. package/src/sync-protocols/common/root.ts +35 -0
  20. package/src/sync-protocols/common/utils.ts +29 -0
  21. package/src/sync-protocols/evm/fetcher.ts +290 -0
  22. package/src/sync-protocols/evm/state.ts +122 -0
  23. package/src/sync-protocols/evm/types.ts +53 -0
  24. package/src/sync-protocols/midnight/MidnightClient.ts +210 -0
  25. package/src/sync-protocols/midnight/fetcher.ts +252 -0
  26. package/src/sync-protocols/midnight/state.ts +147 -0
  27. package/src/sync-protocols/midnight/types.ts +128 -0
  28. package/src/sync-protocols/midnight/zswap-decoder.ts +123 -0
  29. package/src/sync-protocols/mod.ts +29 -0
  30. package/src/sync-protocols/near/NearClient.ts +178 -0
  31. package/src/sync-protocols/near/fetcher.ts +274 -0
  32. package/src/sync-protocols/near/state.ts +130 -0
  33. package/src/sync-protocols/near/types.ts +46 -0
  34. package/src/sync-protocols/ntp/fetcher.ts +199 -0
  35. package/src/sync-protocols/ntp/state.ts +118 -0
  36. package/src/sync-protocols/ntp/types.ts +40 -0
  37. package/src/sync-protocols/orchestration/merge.ts +215 -0
  38. package/src/sync-protocols/orchestration/sync.ts +74 -0
  39. package/src/sync-protocols/types.ts +32 -0
  40. package/src/sync-protocols/utxorpc/BufferedRpc.ts +169 -0
  41. package/src/sync-protocols/utxorpc/fetcher.ts +116 -0
  42. package/src/sync-protocols/utxorpc/state.ts +186 -0
  43. package/src/sync-protocols/utxorpc/types.ts +53 -0
  44. package/src/sync-protocols/utxorpc/utils.ts +80 -0
  45. package/src/syncProtocolFactory.ts +149 -0
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@effectstream/sync",
3
+ "version": "0.100.0",
4
+ "license": "MIT",
5
+ "exports": "./src/mod.ts",
6
+ "dependencies": {
7
+ "@effectstream/config": "0.100.0",
8
+ "@effectstream/db": "0.100.0",
9
+ "@effectstream/log": "0.100.0",
10
+ "@effectstream/utils": "0.100.0",
11
+ "@midnight-ntwrk/ledger-v8": "8.0.3",
12
+ "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
13
+ "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
14
+ "effection": "3.5.0",
15
+ "graphql-ws": "^6.0.6",
16
+ "ntp-time-sync": "^0.5.0",
17
+ "viem": "2.37.3",
18
+ "denque": "^2.1.0",
19
+ "pg": "^8.14.0",
20
+ "json-stable-stringify": "^1.2.1",
21
+ "@cardano-sdk/core": "^0.46.11",
22
+ "@cardano-sdk/util": "^0.17.1",
23
+ "@utxorpc/sdk": "^0.8.0",
24
+ "@utxorpc/spec": "^0.18.1",
25
+ "@sinclair/typebox": "0.34.41",
26
+ "avail-js-sdk": "^0.4.2",
27
+ "buffer": "^6.0.3"
28
+ }
29
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./sync-protocols/mod.ts";
2
+ export * from "./syncProtocolFactory.ts";
3
+ export type { AllSyncProtocols } from "./sync-protocols/types.ts";
@@ -0,0 +1,120 @@
1
+ import { SDK } from "avail-js-sdk";
2
+ import type {
3
+ AvailBlock,
4
+ AvailBlockDataItem,
5
+ AvailBlockHeader,
6
+ AvailStatus,
7
+ } from "./types.ts";
8
+
9
+ const AVAIL_CLIENT_DEFAULT_URL = "http://localhost:7007";
10
+ const AVAIL_NODE_DEFAULT_URL = "ws://localhost:9955/ws";
11
+
12
+ export class AvailClient {
13
+ private readonly url: string;
14
+ private readonly sdk: Promise<SDK>;
15
+ constructor(nodeUrl: string, lightClientUrl: string) {
16
+ this.url = lightClientUrl ?? AVAIL_CLIENT_DEFAULT_URL;
17
+ this.sdk = SDK.New(nodeUrl ?? AVAIL_NODE_DEFAULT_URL);
18
+ }
19
+
20
+ async getStatus(): Promise<AvailStatus> {
21
+ const response = await fetch(`${this.url}/v2/status`);
22
+ if (!response.ok) {
23
+ throw new Error(
24
+ `Failed to get status from light client, status: ${response.status}`,
25
+ );
26
+ }
27
+ const status: AvailStatus = await response.json();
28
+ if (!status) {
29
+ throw new Error(`Failed to get status from light client, no status found`);
30
+ }
31
+ return status;
32
+ }
33
+
34
+ async getLatestBlockHeight(): Promise<number> {
35
+ const api = (await this.sdk).client.api;
36
+ // Block from avail node is slightly above the light client
37
+ // const header = await api.rpc.chain.getHeader();
38
+ const status: AvailStatus = await this.getStatus();
39
+
40
+ // If the available field is present, this is the real latest block
41
+ // synced by the light client for the app-id
42
+ // otherwise the app id is yet not available.
43
+ if (status.blocks.available) {
44
+ return status.blocks.available.last;
45
+ } else {
46
+ return status.blocks.latest - 1;
47
+ }
48
+ }
49
+
50
+ async getBlockFromHash(hash: string): Promise<AvailBlock> {
51
+ const block = await (await this.sdk).client.api.rpc.chain.getBlock(hash);
52
+ return {
53
+ header: block.block.header as unknown as AvailBlockHeader,
54
+ extrinsics: block.block.extrinsics as unknown as string[],
55
+ };
56
+ }
57
+
58
+ async getBlockHeaderFromHeight(height: number): Promise<AvailBlockHeader> {
59
+ const status = await this.getStatus();
60
+ if (!status.blocks.available || height < status.blocks.available.first) {
61
+ // This is before the app is deployed; return a dummy header
62
+ // At this point in time, the app is still not deployed
63
+ return {
64
+ hash: "0x" + String(height) as `0x${string}`,
65
+ parent_hash: "0x0",
66
+ number: height,
67
+ state_root: "0x0",
68
+ extrinsics_root: "0x0",
69
+ extension: {
70
+ rows: 0,
71
+ cols: 0,
72
+ data_root: "0x0",
73
+ commitments: [],
74
+ app_lookup: {
75
+ size: 0,
76
+ index: [],
77
+ },
78
+ },
79
+ received_at: 0,
80
+ }
81
+ }
82
+
83
+ const response = await fetch(`${this.url}/v2/blocks/${height}/header`);
84
+ if (!response.ok) {
85
+ throw new Error(
86
+ `Failed to get block header from height ${height}, status: ${response.status}`,
87
+ );
88
+ }
89
+ const blockHeader = await response.json() as AvailBlockHeader;
90
+ if (!blockHeader) {
91
+ throw new Error(
92
+ `Failed to get block header from height ${height}, no header found`,
93
+ );
94
+ }
95
+ return blockHeader;
96
+ }
97
+
98
+ async getBlockDataFromHeight(height: number): Promise<AvailBlockDataItem> {
99
+ const status = await this.getStatus();
100
+ if (!status.blocks.available || height < status.blocks.available.first) {
101
+ // The app has no data or has not been deployed yet
102
+ // So we return a dummy empty data item
103
+ return { block_number: height, data_transactions: [] }
104
+ }
105
+
106
+ const response = await fetch(`${this.url}/v2/blocks/${height}/data`);
107
+ if (!response.ok) {
108
+ throw new Error(
109
+ `Failed to get block data from height ${height}, status: ${response.status}`,
110
+ );
111
+ }
112
+ const blockData = await response.json() as AvailBlockDataItem;
113
+ if (!blockData) {
114
+ throw new Error(
115
+ `Failed to get block data from height ${height}, no data found`,
116
+ );
117
+ }
118
+ return blockData;
119
+ }
120
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ ConfigSyncProtocolType,
3
+ type PrimitiveEntry,
4
+ type SyncProtocolWithNetwork,
5
+ } from "@effectstream/config";
6
+ import { BaseDataFetcher } from "../base/fetcher.ts";
7
+ import type { DataFetched } from "../base/fetcher.ts";
8
+ import type {
9
+ LastPage,
10
+ OutputAndCleanup,
11
+ RootConversion,
12
+ } from "../base/state.ts";
13
+ import type { RootOutput, RootPage } from "../types.ts";
14
+ import type {
15
+ ConfigType,
16
+ Input,
17
+ Output,
18
+ Page,
19
+ PrimitiveType,
20
+ } from "./types.ts";
21
+ import { AvailClient } from "./AvailClient.ts";
22
+ import { all, call, type Operation, sleep } from "effection";
23
+ import { bound } from "@effectstream/utils";
24
+
25
+ export class AvailFetcher extends BaseDataFetcher<
26
+ Input,
27
+ Output,
28
+ RootOutput,
29
+ Page,
30
+ RootPage
31
+ > {
32
+ readonly client: AvailClient;
33
+
34
+ constructor(
35
+ readonly config: ConfigType,
36
+ ) {
37
+ super(config.syncProtocol.name);
38
+ this.client = new AvailClient(
39
+ config.syncProtocol.rpc,
40
+ config.syncProtocol.lightClient,
41
+ );
42
+ }
43
+
44
+ @bound
45
+ override *readData(
46
+ data: Input,
47
+ rootConversion: RootConversion<Output, RootOutput, RootPage>,
48
+ lastPage: LastPage<Page, RootPage> | undefined,
49
+ ): Operation<DataFetched<Output, Page, RootPage>> {
50
+ const outputs: OutputAndCleanup<Output>[] = [];
51
+ console.log(
52
+ `[Avail] Fetching blocks from ${data.from} to ${data.to}. ${
53
+ data.isPresync ? "[presync]" : ""
54
+ }`,
55
+ );
56
+ for (let height = data.from; height <= data.to; height++) {
57
+ let header;
58
+ try {
59
+ header = yield* call(() =>
60
+ this.client.getBlockHeaderFromHeight(height)
61
+ );
62
+ } catch (_e) {
63
+ // Likely reached the tip; stop here
64
+ break;
65
+ }
66
+
67
+ const primitives = yield* this.readPrimitives(
68
+ height,
69
+ header,
70
+ this.config.primitives,
71
+ );
72
+
73
+ outputs.push({
74
+ output: {
75
+ raw: header,
76
+ primitives,
77
+ },
78
+ cleanup: () => {},
79
+ });
80
+ }
81
+
82
+ if (outputs.length === 0) {
83
+ if (!lastPage) {
84
+ throw new Error(
85
+ `Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
86
+ );
87
+ }
88
+ return {
89
+ output: [],
90
+ lastPage,
91
+ };
92
+ }
93
+
94
+ const lastOutput = outputs[outputs.length - 1].output;
95
+ return {
96
+ output: outputs,
97
+ lastPage: {
98
+ ownBlockNumber: lastOutput.raw.number,
99
+ own: {
100
+ height: lastOutput.raw.number,
101
+ hash: lastOutput.raw.hash,
102
+ },
103
+ root: rootConversion.toRootPage(lastOutput),
104
+ },
105
+ };
106
+ }
107
+
108
+ @bound
109
+ *readPrimitives(
110
+ height: number,
111
+ header: Output["raw"],
112
+ primitiveEntries: Extract<
113
+ PrimitiveEntry,
114
+ { syncProtocol: ConfigSyncProtocolType.AVAIL_PARALLEL }
115
+ >[],
116
+ ): Operation<PrimitiveType[]> {
117
+ const allOperations: Operation<PrimitiveType | undefined>[] = [];
118
+ for (const primitiveEntry of primitiveEntries) {
119
+ allOperations.push(
120
+ this.fetchData(height, primitiveEntry, header),
121
+ );
122
+ }
123
+ return (yield* all(allOperations)).flat().filter(
124
+ Boolean,
125
+ ) as PrimitiveType[];
126
+ }
127
+
128
+ @bound
129
+ *fetchData(
130
+ height: number,
131
+ primitiveEntry: Extract<
132
+ PrimitiveEntry,
133
+ { syncProtocol: ConfigSyncProtocolType.AVAIL_PARALLEL }
134
+ >,
135
+ header: Output["raw"],
136
+ ): Operation<PrimitiveType | undefined> {
137
+ // For now we only read blocks; if the appId appears in the block header
138
+ // we will invoke the data endpoint, but we will not emit a primitive yet.
139
+ const appId = primitiveEntry.primitive.appId;
140
+ if (typeof appId !== "number") return undefined;
141
+
142
+ const isPresent = header.extension.app_lookup.index.some((e) =>
143
+ e.appId === appId
144
+ );
145
+ if (!isPresent) return undefined;
146
+ // Read block data for the corresponding height to unblock future parsing.
147
+ // Intentionally ignore the result for now.
148
+ const blockData = yield* call(() =>
149
+ this.client.getBlockDataFromHeight(height)
150
+ );
151
+ // TODO: handle multiple data transactions
152
+ const dataTransaction = blockData.data_transactions.at(0);
153
+ if (!dataTransaction) return undefined;
154
+ // TODO: is always a JSON string?
155
+ const data: string = atob(dataTransaction.data);
156
+ return {
157
+ syncProtocol: {
158
+ name: primitiveEntry.syncProtocol,
159
+ blockNumber: height,
160
+ transactionHash: header.hash,
161
+ contractAddress: primitiveEntry.primitive.applicationKey,
162
+ },
163
+ primitive: primitiveEntry.primitive.name,
164
+ output: {
165
+ payloadType: "avail-app-state",
166
+ payload: {
167
+ inputData: height.toString(),
168
+ inputNonce: "0x", // TODO replace this with something meaningful
169
+ suppliedValue: data,
170
+ },
171
+ },
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,134 @@
1
+ import { call, type Operation } from "effection";
2
+ import { bound, type TimestampMs } from "@effectstream/utils";
3
+ import type { PoolClient } from "pg";
4
+ import { type LastPage, SyncState } from "../base/state.ts";
5
+ import type { RootOutput, RootPage } from "../types.ts";
6
+ import type { Input, Output, Page } from "./types.ts";
7
+ import { pageRelation } from "./types.ts";
8
+ import type { AvailFetcher } from "./fetcher.ts";
9
+ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/config";
10
+ import { getPage } from "@effectstream/db";
11
+ import { AvailClient } from "./AvailClient.ts";
12
+ import { applyDelay } from "../common/utils.ts";
13
+
14
+ export class AvailSyncState extends SyncState<
15
+ Input,
16
+ Output,
17
+ Page,
18
+ RootOutput,
19
+ RootPage,
20
+ AvailFetcher
21
+ > {
22
+ constructor(
23
+ lastPage: LastPage<Page, RootPage> | undefined,
24
+ readonly config: Extract<
25
+ SyncProtocolWithNetwork,
26
+ { networkType: ConfigNetworkType.AVAIL }
27
+ >,
28
+ fetcher: AvailFetcher,
29
+ public readonly client: AvailClient,
30
+ dbConn: PoolClient,
31
+ ) {
32
+ super(
33
+ config.syncProtocol.name,
34
+ lastPage,
35
+ fetcher,
36
+ pageRelation,
37
+ dbConn,
38
+ );
39
+ }
40
+
41
+ @bound
42
+ override toPage(input: Input, data: Output[]): Page {
43
+ const lastBlock = data[data.length - 1];
44
+ return {
45
+ height: lastBlock.raw.number,
46
+ hash: lastBlock.raw.hash,
47
+ };
48
+ }
49
+
50
+ @bound
51
+ override toRootPage(data: Output): RootPage {
52
+ // Avail headers include `received_at` (sec). Use it as the chain page.
53
+ return applyDelay(
54
+ data.raw.received_at * 1000,
55
+ this.config.syncProtocol.delayMs,
56
+ );
57
+ }
58
+
59
+ @bound
60
+ override toRootOutput(_data: Output): RootOutput {
61
+ throw new Error("Only main chains create root outputs");
62
+ }
63
+
64
+ @bound
65
+ override *stateToInput(): Operation<Input | undefined> {
66
+ const latestHeight = yield* call(() => this.client.getLatestBlockHeight());
67
+ const startHeight = this.lastPage?.own.height ??
68
+ this.config.syncProtocol.startBlockHeight - 1;
69
+
70
+ if (startHeight >= latestHeight) {
71
+ return undefined;
72
+ }
73
+
74
+ const from = startHeight + 1;
75
+ const to = Math.min(
76
+ from + this.config.syncProtocol.stepSize - 1,
77
+ latestHeight,
78
+ );
79
+
80
+ return {
81
+ from,
82
+ to,
83
+ isPresync: false,
84
+ };
85
+ }
86
+
87
+ @bound
88
+ override mergeDatum(ourOutput: Output, rootOutput: RootOutput): void {
89
+ const primitives = ourOutput.primitives.map((p) => ({
90
+ ...p,
91
+ source: this.config.syncProtocol.name,
92
+ }));
93
+ const blockInfo = [{
94
+ protocol_name: this.config.syncProtocol.name,
95
+ block_number: ourOutput.raw.number,
96
+ blockHash: ourOutput.raw.hash,
97
+ }];
98
+ rootOutput.blockInfo.push(...blockInfo);
99
+ rootOutput.primitives.push(...primitives);
100
+ }
101
+
102
+ @bound
103
+ override getNamespace(): string[] {
104
+ return [this.config.network.name, this.config.syncProtocol.name];
105
+ }
106
+
107
+ static *restoreState(
108
+ dbConn: PoolClient,
109
+ config: Extract<
110
+ SyncProtocolWithNetwork,
111
+ { networkType: ConfigNetworkType.AVAIL }
112
+ >,
113
+ fetcher: AvailFetcher,
114
+ ): Operation<AvailSyncState> {
115
+ const [result] = yield* call(async () =>
116
+ await getPage.run({
117
+ protocol_name: config.syncProtocol.name,
118
+ }, dbConn)
119
+ );
120
+ const page = result
121
+ ? result.page as unknown as LastPage<Page, RootPage>
122
+ : undefined;
123
+ return new AvailSyncState(
124
+ page,
125
+ config,
126
+ fetcher,
127
+ new AvailClient(
128
+ config.syncProtocol.rpc,
129
+ config.syncProtocol.lightClient,
130
+ ),
131
+ dbConn,
132
+ );
133
+ }
134
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ ConfigSyncProtocolType,
3
+ FlattenSyncProtocolIOFor,
4
+ SyncProtocolWithNetwork,
5
+ } from "@effectstream/config";
6
+ import type { BlockNumber } from "@effectstream/utils";
7
+ import type { PageSyncRange } from "../common/page-helpers.ts";
8
+ import type { PageRelation } from "../base/page.ts";
9
+
10
+ export type AvailStatus = {
11
+ modes: string[];
12
+ app_id: number;
13
+ genesis_hash: string;
14
+ network: string;
15
+ blocks: {
16
+ latest: number;
17
+ available?: {
18
+ first: number;
19
+ last: number;
20
+ };
21
+ app_data?: {
22
+ first: number;
23
+ last: number;
24
+ };
25
+ };
26
+ }
27
+
28
+ export type AvailBlockHeader = {
29
+ hash: `0x${string}`;
30
+ parent_hash: `0x${string}`;
31
+ number: number;
32
+ state_root: `0x${string}`;
33
+ extrinsics_root: `0x${string}`;
34
+ extension: {
35
+ rows: number;
36
+ cols: number;
37
+ data_root: `0x${string}`;
38
+ commitments: string[];
39
+ app_lookup: {
40
+ size: number;
41
+ index: {
42
+ appId: number;
43
+ start: number;
44
+ }[];
45
+ };
46
+ };
47
+ received_at: number;
48
+ };
49
+
50
+ export type AvailBlockDataItem = {
51
+ block_number: number;
52
+ data_transactions: {
53
+ data: string; // BASE64 encoded data
54
+ extrinsic: string;
55
+ }[];
56
+ };
57
+
58
+ export type AvailBlock = {
59
+ header: AvailBlockHeader;
60
+ extrinsics: string[];
61
+ };
62
+
63
+ // =====================
64
+ // Sync protocol typings
65
+ // =====================
66
+
67
+ export type Page = {
68
+ height: BlockNumber;
69
+ hash: `0x${string}`;
70
+ };
71
+
72
+ export type PrimitiveType = FlattenSyncProtocolIOFor<
73
+ ConfigSyncProtocolType.AVAIL_PARALLEL
74
+ >;
75
+
76
+ export type Input = PageSyncRange<BlockNumber>;
77
+ export type Output = {
78
+ raw: AvailBlockHeader;
79
+ primitives: PrimitiveType[];
80
+ };
81
+
82
+ export const pageRelation: PageRelation<Page> = {
83
+ compare: (p1, p2) => p1.height - p2.height,
84
+ equals: (p1, p2) => p1.height === p2.height,
85
+ min: (p1, p2) => (p1.height < p2.height ? p1 : p2),
86
+ max: (p1, p2) => (p1.height > p2.height ? p1 : p2),
87
+ };
88
+
89
+ export type ConfigType = Extract<
90
+ SyncProtocolWithNetwork,
91
+ { syncProtocolType: ConfigSyncProtocolType.AVAIL_PARALLEL }
92
+ >;
@@ -0,0 +1,43 @@
1
+ import type { Channel, Operation } from "effection";
2
+ import { createChannel } from "effection";
3
+ import type { PageRange } from "./page.ts";
4
+ import type { LastPage, OutputAndCleanup, RootConversion } from "./state.ts";
5
+
6
+ export type DataFetched<Output, Page, RootPage> = {
7
+ output: OutputAndCleanup<Output>[];
8
+ lastPage: LastPage<Page, RootPage>;
9
+ };
10
+
11
+ export abstract class BaseDataFetcher<
12
+ Input,
13
+ Output,
14
+ RootOutput,
15
+ Page,
16
+ RootPage,
17
+ > {
18
+ producerChannel: Channel<Output, void>;
19
+
20
+ constructor(
21
+ readonly name: string,
22
+ ) {
23
+ this.producerChannel = createChannel<Output>();
24
+ }
25
+
26
+ abstract readData(
27
+ data: Input,
28
+ rootConversion: RootConversion<Output, RootOutput, RootPage>,
29
+ // TODO: Search for a better strategy so it's not needed. For now its just for mindnight
30
+ lastPage: LastPage<Page, RootPage> | undefined,
31
+ ): Operation<DataFetched<Output, Page, RootPage>>;
32
+ }
33
+
34
+ /**
35
+ * Useful interface when pagination is done in fixed predictable chunks
36
+ */
37
+ export interface PaginatedFetcher<Page> {
38
+ getLatestPage(knownLastPage: undefined | Page): Operation<Page>;
39
+
40
+ previousInterval(nextIntervalStart: Page): PageRange<Page>;
41
+ nextInterval(prevIntervalEnd: Page): PageRange<Page>;
42
+ intervalFromStart(start: Page): PageRange<Page>;
43
+ }
@@ -0,0 +1,89 @@
1
+ import type { Operation } from "effection";
2
+ import { all, lift, sleep } from "effection";
3
+ import { retry } from "@effectstream/utils";
4
+ import type { IntervalMs, Json } from "@effectstream/utils";
5
+ import stableStringify from "json-stable-stringify";
6
+
7
+ export type PageRange<Page> = {
8
+ from: Page;
9
+ to: Page;
10
+ };
11
+ export interface PageRelation<T> {
12
+ /**
13
+ * -1 → p1 < p2 \
14
+ * 0 → p1 = p2 \
15
+ * +1 → p1 > p2
16
+ */
17
+ compare: (p1: T, p2: T) => number;
18
+ equals: (p1: T, p2: T) => boolean;
19
+ min: (p1: T, p2: T) => T;
20
+ max: (p1: T, p2: T) => T;
21
+ }
22
+
23
+ // ===========================
24
+ // Page request generation
25
+ // (shared between primitives)
26
+ // ===========================
27
+
28
+ export type PageRequest<Page, Result> = (page: Page) => Promise<Result>;
29
+
30
+ export function genImmediatePageRequests<
31
+ Page extends string | number,
32
+ Result,
33
+ >(
34
+ pages: Page[],
35
+ fetchPage: (page: Page) => Promise<Result>,
36
+ ): PageRequest<Page, Result> {
37
+ const fetchedPages = Promise.all(pages.map((page) => fetchPage(page)));
38
+ const result = {} as Record<Page, Promise<Result>>;
39
+ for (let i = 0; i < pages.length; i++) {
40
+ result[pages[i]] = fetchedPages.then((fetched) => fetched[i]);
41
+ }
42
+ return (page: Page) => result[page];
43
+ }
44
+ export function genOnDemandPageRequests<
45
+ Page extends Json,
46
+ Result,
47
+ >(
48
+ fromPage: Page,
49
+ toPage: Page,
50
+ fetchPage: (page: Page) => Promise<Result>,
51
+ pageRelation: PageRelation<Page>,
52
+ ): PageRequest<Page, Result> {
53
+ const promises = {} as Record<string, Promise<Result>>;
54
+ const pageRequest = async (
55
+ page: Page,
56
+ ): Promise<Result> => {
57
+ if (
58
+ pageRelation.compare(page, fromPage) < 0 ||
59
+ pageRelation.compare(page, toPage) > 0
60
+ ) {
61
+ throw new Error(
62
+ `Block out of range. ${page} not in [${fromPage}, ${toPage}]`,
63
+ );
64
+ }
65
+ const jsonKey: string = stableStringify(page)!;
66
+ if (promises[jsonKey] != null) {
67
+ return await promises[jsonKey];
68
+ }
69
+ promises[jsonKey] = fetchPage(page);
70
+ return await promises[jsonKey];
71
+ };
72
+ return pageRequest;
73
+ }
74
+
75
+ export function* fetchNewestPage<Page>(
76
+ pageRelation: PageRelation<Page>,
77
+ getLatestPage: () => Operation<Page>,
78
+ knownLastPage: undefined | Page,
79
+ pollingInterval: IntervalMs,
80
+ ): Operation<Page> {
81
+ return yield* retry<Page>(
82
+ getLatestPage,
83
+ (res) =>
84
+ knownLastPage == null
85
+ ? true
86
+ : pageRelation.compare(res, knownLastPage) > 0,
87
+ () => sleep(pollingInterval),
88
+ );
89
+ }