Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cd3ce61
Add beginning of BaseDataService
FrederikBolding Feb 10, 2026
0737ff7
Support pagination
FrederikBolding Feb 11, 2026
e2d8ae3
Improve pagination
FrederikBolding Feb 11, 2026
e76e1f1
Add invalidateQueries action
FrederikBolding Feb 11, 2026
d693b6e
Account for fetch direction
FrederikBolding Feb 13, 2026
160a9d9
Follow conventions for referencing local packages
FrederikBolding Feb 25, 2026
ddbe52e
Improve typing
FrederikBolding Feb 25, 2026
d699849
Bring test over from other branch
FrederikBolding Feb 25, 2026
283f951
Add example types
FrederikBolding Feb 25, 2026
a8c4e49
Add createUIQueryClient + lint
FrederikBolding Feb 26, 2026
d704123
Improve tests
FrederikBolding Feb 26, 2026
4566433
Add export
FrederikBolding Feb 26, 2026
ab7feb7
Fix pagination test
FrederikBolding Feb 26, 2026
66ce6a4
Fix missing assertion
FrederikBolding Feb 26, 2026
a32bcf7
Revert accidental change
FrederikBolding Feb 26, 2026
c91ee00
Add test for paginated observers
FrederikBolding Feb 26, 2026
faab225
Fix lint
FrederikBolding Feb 26, 2026
09140e7
Fix issues with pagination when query was not already called once
FrederikBolding Feb 27, 2026
82b1b67
Improve example
FrederikBolding Feb 27, 2026
7d7bfd8
Add working test for backwards pagination
FrederikBolding Feb 27, 2026
7df0574
Fix lint
FrederikBolding Feb 27, 2026
51ef661
Unsubscribe cache listeners
FrederikBolding Mar 3, 2026
dac3ca8
Add :cacheUpdate messenger event
FrederikBolding Mar 3, 2026
444149e
Improve typing
FrederikBolding Mar 3, 2026
7c66d47
Improve handling of non data service queries
FrederikBolding Mar 3, 2026
5111712
Simplify
FrederikBolding Mar 3, 2026
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
7 changes: 7 additions & 0 deletions packages/base-data-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,19 @@
"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",
"fast-deep-equal": "^3.1.3"
},
"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
108 changes: 108 additions & 0 deletions packages/base-data-service/src/BaseDataService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Messenger } from '@metamask/messenger';

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

const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520';

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

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, {
after: page1.pageInfo.endCursor,
});

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

expect(page2.data).not.toStrictEqual(page1.data);
});

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

const page2 = await service.getActivity(TEST_ADDRESS, {
after: TRANSACTIONS_PAGE_2_CURSOR,
});

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

const page3 = await service.getActivity(TEST_ADDRESS, {
after: page2.pageInfo.endCursor,
});

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

expect(page3.data).not.toStrictEqual(page2.data);
});

it('handles backwards queries starting at a specific page', async () => {
const messenger = new Messenger({ namespace: serviceName });
const service = new ExampleDataService(messenger);

const page3 = await service.getActivity(TEST_ADDRESS, {
after: TRANSACTIONS_PAGE_3_CURSOR,
});

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

const page2 = await service.getActivity(TEST_ADDRESS, {
before: page3.pageInfo.startCursor,
});

expect(page2.data).toHaveLength(3);
expect(page2.data).not.toStrictEqual(page3.data);
});
});
256 changes: 256 additions & 0 deletions packages/base-data-service/src/BaseDataService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import {
Messenger,
ActionConstraint,
EventConstraint,
ActionHandler,
} 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';
import deepEqual from 'fast-deep-equal';

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 type DataServiceCacheUpdateEvent<ServiceName extends string> = {
type: `${ServiceName}:cacheUpdate`;
payload: [SubscriptionPayload];
};

export type DataServiceEvents<ServiceName extends string> =
DataServiceCacheUpdateEvent<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>,
DataServiceEvents<ServiceName>
>;

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>,
DataServiceEvents<ServiceName>
>;

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

#registerMessageHandlers(): void {
// Casts are required since `registerActionHandler` isn't able to extract the method parameters correctly.
this.#messenger.registerActionHandler(`${this.name}:subscribe`, ((
queryKey: QueryKey,
callback: SubscriptionCallback,
) => this.#handleSubscribe(queryKey, callback)) as ActionHandler<
DataServiceActions<ServiceName>
>);

this.#messenger.registerActionHandler(`${this.name}:unsubscribe`, ((
queryKey: QueryKey,
callback: SubscriptionCallback,
) => this.#handleUnsubscribe(queryKey, callback)) as ActionHandler<
DataServiceActions<ServiceName>
>);

this.#messenger.registerActionHandler(`${this.name}:invalidateQueries`, ((
filters?: InvalidateQueryFilters<Json>,
options?: InvalidateOptions,
) => this.invalidateQueries(filters, options)) as ActionHandler<
DataServiceActions<ServiceName>
>);
}

#setupCacheListener(): void {
this.#client.getQueryCache().subscribe((event) => {
if (['added', 'updated', 'removed'].includes(event.type)) {
this.#broadcastCacheUpdate(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 extends Json = Json,
>(
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 result = await this.#client.fetchInfiniteQuery({
...options,
queryFn: (context) =>
options.queryFn({
...context,
pageParam: context.pageParam ?? pageParam,
}),
});

return result.pages[0];
}

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

const direction = deepEqual(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];
}

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

#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.#getDehydratedState(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);
}
}

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

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

const payload = {
hash,
state,
};

this.#messenger.publish(`${this.name}:cacheUpdate` as const, payload);

// TODO: Determine if we can leverage `messenger.publish` entirely in order to not keep track of subscriptions manually.
const subscribers = this.#subscriptions.get(hash);
subscribers?.forEach((subscriber) => subscriber(payload));
}
}
Loading
Loading