Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/base-data-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/messenger": "^0.3.0",
"@metamask/utils": "^11.9.0",
"@tanstack/query-core": "^4.43.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^29.5.14",
"deepmerge": "^4.2.2",
"jest": "^29.7.0",
"nock": "^13.3.1",
"ts-jest": "^29.2.5",
"typedoc": "^0.25.13",
"typedoc-plugin-missing-exports": "^2.0.0",
Expand Down
68 changes: 68 additions & 0 deletions packages/base-data-service/src/BaseDataService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Messenger } from '@metamask/messenger';

import { ExampleDataService, serviceName } from '../tests/ExampleDataService';
import {
mockAssets,
mockTransactionsPage1,
mockTransactionsPage2,
} from '../tests/mocks';

const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520';

describe('BaseDataService', () => {
beforeEach(() => {
mockAssets();
mockTransactionsPage1();
mockTransactionsPage2();
});

it('handles basic queries', async () => {
const messenger = new Messenger({ namespace: serviceName });
const service = new ExampleDataService(messenger);

expect(
await service.getAssets([
'eip155:1/slip44:60',
'bip122:000000000019d6689c085ae165831e93/slip44:0',
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
]),
).toStrictEqual([
{
assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
decimals: 18,
name: 'Dai Stablecoin',
symbol: 'DAI',
},
{
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
decimals: 8,
name: 'Bitcoin',
symbol: 'BTC',
},
{
assetId: 'eip155:1/slip44:60',
decimals: 18,
name: 'Ethereum',
symbol: 'ETH',
},
]);
});

it('handles paginated queries', async () => {
const messenger = new Messenger({ namespace: serviceName });
const service = new ExampleDataService(messenger);

const page1 = await service.getActivity(TEST_ADDRESS);

expect(page1.data).toHaveLength(3);

const page2 = await service.getActivity(
TEST_ADDRESS,
page1.pageInfo.endCursor,
);

expect(page2.data).toHaveLength(3);

expect(page2.data).not.toStrictEqual(page1.data);
});
});
239 changes: 239 additions & 0 deletions packages/base-data-service/src/BaseDataService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import {
Messenger,
ActionConstraint,
EventConstraint,
} from '@metamask/messenger';
import type { Json } from '@metamask/utils';
import {
DehydratedState,
FetchInfiniteQueryOptions,
FetchQueryOptions,
InfiniteData,
InvalidateOptions,
InvalidateQueryFilters,
QueryClient,
QueryKey,
WithRequired,
dehydrate,
hashQueryKey,
} from '@tanstack/query-core';

export type SubscriptionPayload = { hash: string; state: DehydratedState };
export type SubscriptionCallback = (payload: SubscriptionPayload) => void;

export type DataServiceSubscribeAction<ServiceName extends string> = {
type: `${ServiceName}:subscribe`;
handler: (
queryKey: QueryKey,
callback: SubscriptionCallback,
) => DehydratedState;
};

export type DataServiceUnsubscribeAction<ServiceName extends string> = {
type: `${ServiceName}:unsubscribe`;
handler: (queryKey: QueryKey, callback: SubscriptionCallback) => void;
};

export type DataServiceInvalidateQueriesAction<ServiceName extends string> = {
type: `${ServiceName}:invalidateQueries`;
handler: (
filters?: InvalidateQueryFilters<Json>,
options?: InvalidateOptions,
) => Promise<void>;
};

export type DataServiceActions<ServiceName extends string> =
| DataServiceSubscribeAction<ServiceName>
| DataServiceUnsubscribeAction<ServiceName>
| DataServiceInvalidateQueriesAction<ServiceName>;

export class BaseDataService<
ServiceName extends string,
ServiceMessenger extends Messenger<
ServiceName,
ActionConstraint,
EventConstraint,
// Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway,
// it's the one totally safe place to use it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>,
> {
public readonly name: ServiceName;

readonly #messenger: Messenger<
ServiceName,
DataServiceActions<ServiceName>,
never
>;

readonly #client = new QueryClient();

readonly #subscriptions: Map<string, Set<SubscriptionCallback>> = new Map();

constructor({
name,
messenger,
}: {
name: ServiceName;
messenger: ServiceMessenger;
}) {
this.name = name;

this.#messenger = messenger as unknown as Messenger<
ServiceName,
DataServiceActions<ServiceName>,
never
>;

this.#registerMessageHandlers();
this.#setupCacheListener();
}

#registerMessageHandlers(): void {
this.#messenger.registerActionHandler(
`${this.name}:subscribe`,
// @ts-expect-error TODO.
(queryKey: QueryKey, callback: SubscriptionCallback) =>
this.#handleSubscribe(queryKey, callback),
);

this.#messenger.registerActionHandler(
`${this.name}:unsubscribe`,
// @ts-expect-error TODO.
(queryKey: QueryKey, callback: SubscriptionCallback) =>
this.#handleUnsubscribe(queryKey, callback),
);

this.#messenger.registerActionHandler(
`${this.name}:invalidateQueries`,
// @ts-expect-error TODO.
(filters?: InvalidateQueryFilters<Json>, options?: InvalidateOptions) =>
this.invalidateQueries(filters, options),
);
}

#setupCacheListener(): void {
this.#client.getQueryCache().subscribe((event) => {
if (this.#subscriptions.has(event.query.queryHash)) {
this.#broadcastQueryState(event.query.queryKey);
}
});
}

protected async fetchQuery<
TQueryFnData extends Json,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
options: WithRequired<
FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
): Promise<TData> {
return this.#client.fetchQuery(options);
}

protected async fetchInfiniteQuery<
TQueryFnData extends Json,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
>(
options: WithRequired<
FetchInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
pageParam?: TPageParam,
): Promise<TData> {
const query = this.#client
.getQueryCache()
.find<TQueryFnData, TError, TData>({ queryKey: options.queryKey });

if (query && pageParam) {
const pages =
(query.state.data as InfiniteData<TQueryFnData> | undefined)?.pages ??
[];
const previous = options.getPreviousPageParam?.(pages[0], pages);

const direction = pageParam === previous ? 'backward' : 'forward';

const result = (await query.fetch(undefined, {
meta: {
fetchMore: {
direction,
pageParam,
},
},
})) as InfiniteData<TData>;

const pageIndex = result.pageParams.indexOf(pageParam);

return result.pages[pageIndex];
}

const result = await this.#client.fetchInfiniteQuery(options);

return result.pages[0];
}

protected async invalidateQueries<TPageData extends Json>(
filters?: InvalidateQueryFilters<TPageData>,
options?: InvalidateOptions,
): Promise<void> {
return this.#client.invalidateQueries(filters, options);
}

// TODO: Determine if this has a better fit with `messenger.publish`.
#handleSubscribe(
queryKey: QueryKey,
subscription: SubscriptionCallback,
): DehydratedState {
const hash = hashQueryKey(queryKey);

if (!this.#subscriptions.has(hash)) {
this.#subscriptions.set(hash, new Set());
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.#subscriptions.get(hash)!.add(subscription);

return this.#getDehydratedStateForQuery(queryKey);
}

#handleUnsubscribe(
queryKey: QueryKey,
subscription: SubscriptionCallback,
): void {
const hash = hashQueryKey(queryKey);
const subscribers = this.#subscriptions.get(hash);

subscribers?.delete(subscription);
if (subscribers?.size === 0) {
this.#subscriptions.delete(hash);
}
}

#getDehydratedStateForQuery(queryKey: QueryKey): DehydratedState {
const hash = hashQueryKey(queryKey);
return dehydrate(this.#client, {
shouldDehydrateQuery: (query) => query.queryHash === hash,
});
}

#broadcastQueryState(queryKey: QueryKey): void {
const hash = hashQueryKey(queryKey);
const state = this.#getDehydratedStateForQuery(queryKey);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const subscribers = this.#subscriptions.get(hash)!;
subscribers.forEach((subscriber) =>
subscriber({
hash,
state,
}),
);
}
}
Loading
Loading