Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/config/nexus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ export const NEXUS_NOTIFICATIONS_LIMIT = 30;
export const NEXUS_POSTS_PER_PAGE = 10; // Number of posts to fetch per page in streams
export const NEXUS_STREAM_MAX_LIMIT = 50; // Hard cap Nexus enforces on a single stream `limit`; requests above this are rejected
export const NEXUS_USERS_PER_PAGE = 10; // Number of users to fetch per page in streams

/**
* Nexus contract limit: `source=starter_pack` accepts 1-5 comma-separated interest tags
* (pubky/pubky-nexus#1024). Fixed by the backend — independent of the runtime-configurable
* `getMaxStreamTags()`, which may be set higher and must never widen this bound.
*/
export const STARTER_PACK_MAX_TAGS = 5;
15 changes: 14 additions & 1 deletion src/core/application/stream/users/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { UserStreamApplication } from '@/application/stream/users/users';
import type { Pubky } from '@/models/models.types';
import { buildUserCompositeId } from '@/models/stream/user/userStream.helper';
import { UserStreamTypes } from '@/models/stream/user/userStream.types';
import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types';
import { UserDetailsModel } from '@/models/user/details/userDetails';
import { LocalStreamUsersService } from '@/services/local/stream/users/users';
import type { NexusUser } from '@/services/nexus/nexus.types';
Expand Down Expand Up @@ -289,6 +289,19 @@ describe('UserStreamApplication', () => {
expect(result.nextPageIds).toEqual(['influencer-1', 'influencer-2', 'influencer-3']);
});

describe('starter pack caching', () => {
it('should persist reversed tag orders as distinct Dexie rows', async () => {
const forwardId = 'starter_pack:all:all:travel,music' as UserStreamId;
const reversedId = 'starter_pack:all:all:music,travel' as UserStreamId;

await LocalStreamUsersService.upsert({ streamId: forwardId, stream: ['user-a'] });
await LocalStreamUsersService.upsert({ streamId: reversedId, stream: ['user-b'] });

expect((await LocalStreamUsersService.findById(forwardId))?.stream).toEqual(['user-a']);
expect((await LocalStreamUsersService.findById(reversedId))?.stream).toEqual(['user-b']);
});
});

it('should pass viewerId to Nexus API for relationship data', async () => {
const streamId = buildUserCompositeId({ userId: DEFAULT_USER_ID, reach: 'followers' });
const mockUserIds: Pubky[] = ['follower-1', 'follower-2'];
Expand Down
87 changes: 87 additions & 0 deletions src/core/models/stream/user/userStream.helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { AppError } from '@/libs/error/error';
import { ValidationErrorCode } from '@/libs/error/error.codes';
import { ErrorCategory } from '@/libs/error/error.types';
import { buildStarterPackStreamId, USER_STREAM_TAG_DELIMITER } from './userStream.helper';

const expectValidationError = (fn: () => unknown) => {
try {
fn();
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
const appError = error as AppError;
expect(appError.category).toBe(ErrorCategory.Validation);
expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT);
}
};

describe('buildStarterPackStreamId', () => {
describe('ID construction', () => {
it('should join ordered tags under the starter_pack source', () => {
expect(buildStarterPackStreamId(['bitcoin', 'music'])).toBe('starter_pack:all:all:bitcoin,music');
});

it('should preserve tag order (reversed lists yield distinct IDs)', () => {
const forward = buildStarterPackStreamId(['travel', 'music']);
const reversed = buildStarterPackStreamId(['music', 'travel']);

expect(forward).toBe('starter_pack:all:all:travel,music');
expect(reversed).toBe('starter_pack:all:all:music,travel');
expect(forward).not.toBe(reversed);
});

it('should canonicalize labels so casing/whitespace variants map to one ID', () => {
const fromMixedCase = buildStarterPackStreamId(['Bitcoin ', 'MUSIC']);
const fromCanonical = buildStarterPackStreamId(['bitcoin', 'music']);

expect(fromMixedCase).toBe(fromCanonical);
expect(fromMixedCase).toBe('starter_pack:all:all:bitcoin,music');
});

it('should accept the maximum of 5 tags', () => {
expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack:all:all:a,b,c,d,e');
});

it('should accept a single tag', () => {
expect(buildStarterPackStreamId(['bitcoin'])).toBe('starter_pack:all:all:bitcoin');
});
});

describe('validation', () => {
it('should reject an empty tag list', () => {
expectValidationError(() => buildStarterPackStreamId([]));
});

it('should reject more than 5 tags', () => {
expectValidationError(() => buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e', 'f']));
});

it('should reject empty and whitespace-only labels', () => {
expectValidationError(() => buildStarterPackStreamId(['']));
expectValidationError(() => buildStarterPackStreamId([' ']));
});

it('should reject labels with inner whitespace (banned characters)', () => {
expectValidationError(() => buildStarterPackStreamId(['rock music']));
expectValidationError(() => buildStarterPackStreamId(['tab\there']));
expectValidationError(() => buildStarterPackStreamId(['new\nline']));
});

it('should reject labels containing the tag delimiter', () => {
expectValidationError(() => buildStarterPackStreamId([`bit${USER_STREAM_TAG_DELIMITER}coin`]));
});

it('should reject labels containing the stream ID delimiter', () => {
expectValidationError(() => buildStarterPackStreamId(['bit:coin']));
});

it('should reject overlength labels (>20 chars)', () => {
expectValidationError(() => buildStarterPackStreamId(['a'.repeat(21)]));
});

it('should accept a label at exactly 20 chars', () => {
expect(buildStarterPackStreamId(['a'.repeat(20)])).toContain(`:${'a'.repeat(20)}`);
});
});
});
56 changes: 55 additions & 1 deletion src/core/models/stream/user/userStream.helper.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { STARTER_PACK_MAX_TAGS } from '@/config/nexus';
import { ValidationErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
import { isValidTagLabel } from '@/libs/utils/utils';
import type { Pubky } from '@/models/models.types';
import { UserStreamModelSchema } from './userStream.schema';
import { UserStreamCompositeId, UserStreamId } from './userStream.types';
import {
STARTER_PACK_STREAM_SOURCE,
StarterPackStreamId,
UserStreamCompositeId,
UserStreamId,
} from './userStream.types';

export const USER_STREAM_ID_DELIMITER = ':' as const;
export const USER_STREAM_TAG_DELIMITER = ',' as const;

/**
* Parts of a user stream composite ID
Expand Down Expand Up @@ -46,6 +57,49 @@ export function parseUserCompositeId(compositeId: string): UserStreamIdParts {
};
}

/**
* Build a starter pack stream ID from ordered interest tags.
*
* Labels are canonicalized (trimmed + lowercased) so 'Bitcoin' and 'bitcoin' resolve to the same
* Dexie row, then validated against the canonical tag contract (1-20 chars, no banned characters)
* and the Nexus starter pack limit (1-5 tags). Order is preserved: Nexus interleaves per-tag
* rankings in the order given, so ['travel','music'] and ['music','travel'] are different streams.
*
* @example
* buildStarterPackStreamId(['Bitcoin ', 'music'])
* // Returns: 'starter_pack:all:all:bitcoin,music'
*/
export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId {
const canonical = tags.map((tag) => tag.trim().toLowerCase());

if (canonical.length === 0 || canonical.length > STARTER_PACK_MAX_TAGS) {
throw Err.validation(
ValidationErrorCode.INVALID_INPUT,
`Starter pack streams require 1-${STARTER_PACK_MAX_TAGS} tags`,
{
service: ErrorService.Nexus,
operation: 'buildStarterPackStreamId',
context: { tagCount: canonical.length },
},
);
}

const invalidLabels = canonical.filter((tag) => !isValidTagLabel(tag));
if (invalidLabels.length > 0) {
throw Err.validation(
ValidationErrorCode.INVALID_INPUT,
'Starter pack tags must be 1-20 characters without banned characters',
{
service: ErrorService.Nexus,
operation: 'buildStarterPackStreamId',
context: { invalidLabels },
},
);
}

return `${STARTER_PACK_STREAM_SOURCE}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId;
}

export const createDefaultUserStream = (id: UserStreamId, stream: Pubky[] = []): UserStreamModelSchema => {
return {
id,
Expand Down
12 changes: 11 additions & 1 deletion src/core/models/stream/user/userStream.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type { UserStreamCompositeReach } from '@/services/nexus/nexus.types';
// - TIMEFRAME: today, this_week, this_month, all_time, all
// - REACH (Supported in 'influencers' source): followers, following, friends, wot (u8), all
//
// Starter pack IDs extend the pattern with a 4th ordered-tag segment: source:timeframe:reach:tag1,tag2
// - SOURCE: starter_pack (pubky/pubky-nexus#1024)
// - Tag order matters: Nexus interleaves per-tag rankings in the order given.
//
// Note: Different from PostStreamTypes pattern (sorting:source:kind) to optimize for user-centric queries
export enum UserStreamTypes {
// Bootstrap default lists:
Expand All @@ -17,7 +21,13 @@ export enum UserStreamTypes {
// TODO: Add all possible cases
}

// Nexus stream source for starter packs (pubky/pubky-nexus#1024)
export const STARTER_PACK_STREAM_SOURCE = 'starter_pack' as const;

// Starter pack ID format: source:all:all:tag1,tag2 (ordered, canonicalized tags)
export type StarterPackStreamId = `${typeof STARTER_PACK_STREAM_SOURCE}:all:all:${string}`;

// Composite ID format: userId:reach (e.g., 'user123:followers')
export type UserStreamCompositeId = `${Pubky}:${UserStreamCompositeReach}`;

export type UserStreamId = UserStreamTypes | UserStreamCompositeId;
export type UserStreamId = UserStreamTypes | UserStreamCompositeId | StarterPackStreamId;
5 changes: 5 additions & 0 deletions src/core/services/nexus/stream/users/userStream.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type TUserStreamInfluencersParams,
type TUserStreamPostRepliesParams,
type TUserStreamQueryParams,
type TUserStreamStarterPackParams,
type TUserStreamUsernameParams,
type TUserStreamUsersByIdsParams,
type TUserStreamWithDepthParams,
Expand Down Expand Up @@ -76,6 +77,10 @@ export const userStreamApi = {
mostFollowed: (params: TUserStreamBase) =>
buildUserStreamUrl(params, UserStreamSource.MOST_FOLLOWED, USER_STREAM_PREFIX.USER_IDS),

// Starter pack: ranked users for 1-5 ordered interest tags (pubky/pubky-nexus#1024)
starterPack: (params: TUserStreamStarterPackParams) =>
buildUserStreamUrl(params, UserStreamSource.STARTER_PACK, USER_STREAM_PREFIX.USER_IDS),

// Username search
username: (params: TUserStreamUsernameParams) => buildUserStreamUrl(params, null, USER_STREAM_PREFIX.USERNAME),

Expand Down
63 changes: 59 additions & 4 deletions src/core/services/nexus/stream/users/userStream.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Pubky } from '@/models/models.types';
import { buildUserCompositeId } from '@/models/stream/user/userStream.helper';
import { UserStreamTypes } from '@/models/stream/user/userStream.types';
import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types';
import { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types';
import { queryNexus } from '@/services/nexus/nexus.utils';
import { asInvalid } from '@/test-utils/type-assertions';
import { NexusUserStreamService } from './userStream';
import { buildUserStreamBodyUrl, userStreamApi } from './userStream.api';

vi.mock('@/services/nexus/nexus.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/services/nexus/nexus.utils')>()),
queryNexus: vi.fn(),
}));

describe('Users Stream API - Error Control', () => {
const mockUserId = 'erztyis9oiaho93ckucetcf5xnxacecqwhbst5hnd7mmkf69dhby';
const mockViewerId = 'viewer-pubky-id';
Expand Down Expand Up @@ -290,9 +298,9 @@ describe('Users Stream API - Error Control', () => {
});

describe('UserStreamApiEndpoint type', () => {
it('should have exactly 10 endpoints', () => {
it('should have exactly 11 endpoints', () => {
const endpointKeys = Object.keys(userStreamApi);
expect(endpointKeys).toHaveLength(10);
expect(endpointKeys).toHaveLength(11);
expect(endpointKeys).toContain('followers');
expect(endpointKeys).toContain('following');
expect(endpointKeys).toContain('friends');
Expand All @@ -301,6 +309,7 @@ describe('Users Stream API - Error Control', () => {
expect(endpointKeys).toContain('postReplies');
expect(endpointKeys).toContain('friendsWithDepth');
expect(endpointKeys).toContain('mostFollowed');
expect(endpointKeys).toContain('starterPack');
expect(endpointKeys).toContain('username');
expect(endpointKeys).toContain('usersByIds');
});
Expand Down Expand Up @@ -395,6 +404,32 @@ describe('NexusUserStreamService.fetch', () => {

expect(url).toContain('source=recommended');
});

it('should generate correct starter pack URL with ordered comma-joined tags', () => {
const url = userStreamApi.starterPack({
tags: 'bitcoin,music',
viewer_id: mockUserId,
skip: 0,
limit: 10,
});

expect(url).toContain('v0/stream/users/ids?');
expect(url).toContain('source=starter_pack');
// URLSearchParams encodes the comma; order must be preserved
expect(url).toContain('tags=bitcoin%2Cmusic');
expect(url).toContain(`viewer_id=${mockUserId}`);
expect(url).toContain('skip=0');
expect(url).toContain('limit=10');
});

it('should generate distinct starter pack URLs for reversed tag orders', () => {
const forward = userStreamApi.starterPack({ tags: 'travel,music' });
const reversed = userStreamApi.starterPack({ tags: 'music,travel' });

expect(forward).toContain('tags=travel%2Cmusic');
expect(reversed).toContain('tags=music%2Ctravel');
expect(forward).not.toBe(reversed);
});
});

describe('Parameter handling', () => {
Expand Down Expand Up @@ -444,6 +479,26 @@ describe('NexusUserStreamService.fetch', () => {
});
});

describe('starter pack dispatch', () => {
beforeEach(() => {
vi.mocked(queryNexus).mockReset();
vi.mocked(queryNexus).mockResolvedValue([]);
});

it('should dispatch starter pack IDs to source=starter_pack with ordered tags', async () => {
await NexusUserStreamService.fetch({
streamId: 'starter_pack:all:all:bitcoin,music' as UserStreamId,
params: { skip: 0, limit: 10, viewer_id: 'viewer-abc' as Pubky },
});

expect(queryNexus).toHaveBeenCalledTimes(1);
const { url } = vi.mocked(queryNexus).mock.calls[0][0];
expect(url).toContain('source=starter_pack');
expect(url).toContain('tags=bitcoin%2Cmusic');
expect(url).toContain('viewer_id=viewer-abc');
});
});

describe('URL structure validation', () => {
it('should always start with v0/stream/users/ids?', () => {
const url = userStreamApi.followers({
Expand Down
21 changes: 19 additions & 2 deletions src/core/services/nexus/stream/users/userStream.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ValidationErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
import { HttpMethod } from '@/libs/http/http.types';
import type { Pubky } from '@/models/models.types';
import type { NexusUser, NexusUserIdsStream } from '@/services/nexus/nexus.types';
Expand All @@ -7,6 +10,7 @@ import type {
TFetchUserStreamParams,
TUserStreamBase,
TUserStreamInfluencersParams,
TUserStreamStarterPackParams,
TUserStreamUsersByIdsParams,
TUserStreamWithUserIdParams,
} from '@/services/nexus/stream/users/userStream.types';
Expand Down Expand Up @@ -44,8 +48,21 @@ export class NexusUserStreamService {
case 'most_followed':
url = userStreamApi.mostFollowed(apiParams as TUserStreamBase);
break;
default:
throw new Error(`Invalid reach type: ${reach}`);
case 'starter_pack':
url = userStreamApi.starterPack(apiParams as TUserStreamStarterPackParams);
break;
default: {
const exhaustiveCheck: never = reach;
throw Err.validation(
ValidationErrorCode.INVALID_INPUT,
`Unsupported user stream reach: ${String(exhaustiveCheck)}`,
{
service: ErrorService.Nexus,
operation: 'fetch',
context: { streamId },
},
);
}
}

return await queryNexus<NexusUserIdsStream>({ url });
Expand Down
Loading
Loading