diff --git a/AGENTS.md b/AGENTS.md index 0f73a96..36ec5ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,12 @@ This is a TypeScript CLI (`tw`) for Twist messaging, built with Commander.js. - **Named flag aliases**: Where commands accept positional `[workspace-ref]`, the `--workspace` flag is also accepted. Error if both positional and flag are provided - **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/away.ts` as the reference implementation. - **Spinner messages**: When adding new SDK method calls, add a corresponding entry in the `API_SPINNER_MESSAGES` map in `src/lib/api.ts`. Every user-facing API call should have a spinner message so the CLI shows progress feedback. +- **Batch API responses**: When calling `client.batch(...)`, never access `.data` directly on a batch result. Use these helpers from `src/lib/api.ts`: + - `assertBatchData(response, label)` — single result; throws `CliError` on any failure. + - `buildBatchNameMap(ids, responses, label)` — strict parallel lookup; use when every id must resolve (e.g. channels). + - `buildOptionalBatchNameMap(ids, responses, label)` — tolerant parallel lookup; skips entries with `data: null` and a success code (e.g. deleted users) but still throws on real API errors. Callers must fall back via `userMap.get(id) ?? \`user:${id}\``. Use for user lookups so a single missing user doesn't abort the whole command. + + See `src/commands/inbox.ts` (strict) and `src/commands/thread/view.ts` (tolerant) for reference. ## Error Handling diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 179c2a6..9d8ff04 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -1,4 +1,5 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' +import type { BatchResponse as TwistBatchResponse } from '@doist/twist-sdk' import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../../lib/errors.js' @@ -15,7 +16,10 @@ const refsMocks = vi.hoisted(() => ({ resolveUserRefs: vi.fn(), })) -vi.mock('../../lib/api.js', () => apiMocks) +vi.mock('../../lib/api.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...apiMocks, +})) vi.mock('../../lib/refs.js', () => refsMocks) @@ -63,6 +67,8 @@ function createConversation(id: number, userIds: number[], lastActive: string): } } +type BatchResult = Pick, 'code' | 'data'> + function createClient({ activeConversations = [], archivedConversations = [], @@ -165,16 +171,19 @@ function createClient({ userId?: number conversationId?: number }> - ) => - requests.map((request) => { + ): Promise => + requests.map((request): BatchResult => { if (request.kind === 'conversation' && request.id) { - return { data: conversationsById.get(request.id) } + return { code: 200, data: conversationsById.get(request.id) } } if (request.kind === 'messages') { - return { data: messagesByConversation[request.conversationId ?? -1] ?? [] } + return { + code: 200, + data: messagesByConversation[request.conversationId ?? -1] ?? [], + } } if (request.kind === 'user' && request.userId) { - return { data: users[request.userId] } + return { code: 200, data: users[request.userId] } } throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`) }), @@ -551,6 +560,51 @@ describe('conversation view machine output', () => { }) }) +describe('conversation view with failed batch response', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws a clear error when a batched user lookup fails', async () => { + const conversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') + const messages = [ + { + id: 99, + content: '**hello**', + creator: 2, + conversationId: 42, + posted: new Date('2026-03-08T10:05:00.000Z'), + reactions: [], + }, + ] + const client = createClient({ + activeConversations: [conversation], + messagesByConversation: { 42: messages }, + users: { + 1: { id: 1, name: 'Me' }, + 2: { id: 2, name: 'Alice Example' }, + }, + }) + + client.batch + .mockResolvedValueOnce([ + { code: 200, data: conversation }, + { code: 200, data: messages }, + ]) + .mockResolvedValueOnce([ + { code: 200, data: { id: 1, name: 'Me' } }, + { code: 403, data: { errorString: 'User lookup failed' } }, + ]) + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tw', 'conversation', 'view', '42']), + ).rejects.toThrow('Failed to fetch user 2: User lookup failed') + }) +}) + describe('conversation mute', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index 6dc9c27..3d33f07 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -1,6 +1,6 @@ import type { Conversation } from '@doist/twist-sdk' import chalk from 'chalk' -import { getTwistClient } from '../../lib/api.js' +import { buildOptionalBatchNameMap, getTwistClient } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' import { CliError } from '../../lib/errors.js' import { isAccessible } from '../../lib/global-args.js' @@ -179,11 +179,12 @@ export async function listConversationsWithUser( } } - const userCalls = [...userIds].map((userId) => + const userIdList = [...userIds] + const userCalls = userIdList.map((userId) => client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), ) const userResponses = await client.batch(...userCalls) - const userMap = new Map(userResponses.map((response) => [response.data.id, response.data.name])) + const userMap = buildOptionalBatchNameMap(userIdList, userResponses, 'user') const output = conversations.map((conversation) => ({ ...conversation, diff --git a/src/commands/conversation/unread.ts b/src/commands/conversation/unread.ts index 32a430d..e21a59f 100644 --- a/src/commands/conversation/unread.ts +++ b/src/commands/conversation/unread.ts @@ -1,5 +1,10 @@ import chalk from 'chalk' -import { getCurrentWorkspaceId, getTwistClient } from '../../lib/api.js' +import { + assertBatchData, + buildOptionalBatchNameMap, + getCurrentWorkspaceId, + getTwistClient, +} from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { isAccessible } from '../../lib/global-args.js' import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js' @@ -39,7 +44,12 @@ export async function showUnread( client.conversations.getConversation(uc.conversationId, { batch: true }), ) const conversationResponses = await client.batch(...conversationCalls) - const conversations = conversationResponses.map((r) => r.data) + const conversations = unreadConversations.map((conversation, index) => + assertBatchData( + conversationResponses[index], + `conversation ${conversation.conversationId}`, + ), + ) const userIds = new Set() for (const conv of conversations) { @@ -48,11 +58,12 @@ export async function showUnread( } } - const userCalls = [...userIds].map((id) => + const userIdList = [...userIds] + const userCalls = userIdList.map((id) => client.workspaceUsers.getUserById({ workspaceId, userId: id }, { batch: true }), ) const userResponses = await client.batch(...userCalls) - const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name])) + const userMap = buildOptionalBatchNameMap(userIdList, userResponses, 'user') if (options.json) { const output = conversations.map((c) => ({ diff --git a/src/commands/conversation/view.ts b/src/commands/conversation/view.ts index 3b78793..1c8cd29 100644 --- a/src/commands/conversation/view.ts +++ b/src/commands/conversation/view.ts @@ -1,5 +1,5 @@ import chalk from 'chalk' -import { getTwistClient } from '../../lib/api.js' +import { assertBatchData, buildOptionalBatchNameMap, getTwistClient } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' import { renderMarkdown } from '../../lib/markdown.js' import { colors, filterEntityFields } from '../../lib/output.js' @@ -25,18 +25,23 @@ export async function viewConversation( ), ) - const conversation = convResponse.data - const messages = messagesResponse.data + const conversation = assertBatchData(convResponse, `conversation ${conversationId}`) + const messages = assertBatchData( + messagesResponse, + `messages for conversation ${conversationId}`, + ) - const userIds = new Set([...conversation.userIds, ...messages.map((m) => m.creator)]) - const userCalls = [...userIds].map((id) => + const userIds = [ + ...new Set([...conversation.userIds, ...messages.map((m) => m.creator)]), + ] + const userCalls = userIds.map((id) => client.workspaceUsers.getUserById( { workspaceId: conversation.workspaceId, userId: id }, { batch: true }, ), ) const userResponses = await client.batch(...userCalls) - const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name])) + const userMap = buildOptionalBatchNameMap(userIds, userResponses, 'user') const conversationOutput = { ...conversation, participantNames: conversation.userIds.map((id) => userMap.get(id)), diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index a709de0..1da4014 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -7,9 +7,9 @@ const apiMocks = vi.hoisted(() => ({ getCurrentWorkspaceId: vi.fn(), })) -vi.mock('../lib/api.js', () => ({ - getTwistClient: apiMocks.getTwistClient, - getCurrentWorkspaceId: apiMocks.getCurrentWorkspaceId, +vi.mock('../lib/api.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...apiMocks, })) vi.mock('../lib/refs.js', () => ({ @@ -60,7 +60,10 @@ describe('inbox --archive-filter', () => { apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) mockGetInbox.mockReturnValue({ data: [] }) mockGetUnread.mockReturnValue({ data: [] }) - mockBatch.mockResolvedValue([{ data: [] }, { data: [] }]) + mockBatch.mockResolvedValue([ + { code: 200, data: [] }, + { code: 200, data: [] }, + ]) apiMocks.getTwistClient.mockResolvedValue({ inbox: { getInbox: mockGetInbox }, threads: { getUnread: mockGetUnread }, @@ -121,7 +124,10 @@ describeEmptyMachineOutput('inbox empty output', { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) emptyInboxMockBatch.mockImplementation((..._calls: unknown[]) => - Promise.resolve([{ data: [] }, { data: [] }]), + Promise.resolve([ + { code: 200, data: [] }, + { code: 200, data: [] }, + ]), ) apiMocks.getTwistClient.mockResolvedValue({ inbox: { getInbox: vi.fn().mockReturnValue({ data: [] }) }, @@ -152,8 +158,11 @@ describe('inbox empty output (channel filter)', () => { url: 'http://example/t', } mockBatch - .mockResolvedValueOnce([{ data: [thread] }, { data: [] }]) - .mockResolvedValueOnce([{ data: { id: 10, name: 'engineering' } }]) + .mockResolvedValueOnce([ + { code: 200, data: [thread] }, + { code: 200, data: [] }, + ]) + .mockResolvedValueOnce([{ code: 200, data: { id: 10, name: 'engineering' } }]) apiMocks.getTwistClient.mockResolvedValue({ inbox: { getInbox: vi.fn() }, threads: { getUnread: vi.fn() }, @@ -171,3 +180,36 @@ describe('inbox empty output (channel filter)', () => { expect(logSpy).toHaveBeenCalledWith('[]') }) }) + +describe('inbox batch errors', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + }) + + it('surfaces the API error instead of crashing when a batch request fails', async () => { + const mockBatch = vi.fn().mockResolvedValue([ + { code: 400, data: { errorString: 'limit must be less than or equal to 500' } }, + { code: 200, data: [] }, + ]) + apiMocks.getTwistClient.mockResolvedValue({ + inbox: { + getInbox: vi.fn((_args: unknown, options?: { batch?: boolean }) => + options?.batch ? { kind: 'inbox' } : Promise.resolve([]), + ), + }, + threads: { + getUnread: vi.fn((_workspaceId: number, options?: { batch?: boolean }) => + options?.batch ? { kind: 'unread' } : Promise.resolve([]), + ), + }, + batch: mockBatch, + }) + + const program = createProgram() + + await expect( + program.parseAsync(['node', 'tw', 'inbox', '--unread', '--limit', '1000']), + ).rejects.toThrow('Failed to fetch inbox threads: limit must be less than or equal to 500') + }) +}) diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index 7e4e6a7..8cbdb1d 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -1,7 +1,12 @@ import type { ArchiveFilter } from '@doist/twist-sdk' import chalk from 'chalk' import { Command, Option } from 'commander' -import { getCurrentWorkspaceId, getTwistClient } from '../lib/api.js' +import { + assertBatchData, + buildBatchNameMap, + getCurrentWorkspaceId, + getTwistClient, +} from '../lib/api.js' import { withCaseInsensitiveChoices } from '../lib/completion.js' import { formatRelativeDate } from '../lib/dates.js' import { CliError } from '../lib/errors.js' @@ -39,7 +44,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const client = await getTwistClient() const limit = options.limit ? parseInt(options.limit, 10) : 50 - const [threads, unreadData] = await client.batch( + const [threadsResponse, unreadResponse] = await client.batch( client.inbox.getInbox( { workspaceId, @@ -53,8 +58,10 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions client.threads.getUnread(workspaceId, { batch: true }), ) - const unreadThreadIds = new Set(unreadData.data.map((u) => u.threadId)) - let inboxThreads = threads.data.map((t) => ({ + const threads = assertBatchData(threadsResponse, 'inbox threads') + const unreadData = assertBatchData(unreadResponse, 'unread threads') + const unreadThreadIds = new Set(unreadData.map((u) => u.threadId)) + let inboxThreads = threads.map((t) => ({ ...t, isUnread: unreadThreadIds.has(t.id), })) @@ -71,7 +78,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions const channelIds = [...new Set(inboxThreads.map((t) => t.channelId))] const channelCalls = channelIds.map((id) => client.channels.getChannel(id, { batch: true })) const channelResponses = await client.batch(...channelCalls) - const channelMap = new Map(channelResponses.map((r) => [r.data.id, r.data.name])) + const channelMap = buildBatchNameMap(channelIds, channelResponses, 'channel') if (!includePrivateChannels()) { const publicIds = await getPublicChannelIds(workspaceId) diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index b131a5d..1c43980 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -1,3 +1,4 @@ +import type { BatchResponse as TwistBatchResponse } from '@doist/twist-sdk' import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -73,6 +74,8 @@ function createComment(id: number, objIndex: number) { } } +type BatchResult = Pick, 'code' | 'data'> + function createClient({ thread = createThreadFixture(500), comments = [] as ReturnType[], @@ -160,28 +163,34 @@ function createClient({ }, ), }, - batch: vi.fn(async (...requests: Array<{ kind: string; id?: number; userId?: number }>) => - requests.map((request) => { - if (request.kind === 'thread') return { code: 200, data: thread } - if (request.kind === 'comments') return { code: 200, data: comments } - if (request.kind === 'comment') - return { - code: 200, - data: comments.find((c) => c.id === request.id) ?? comments[0], + batch: vi.fn( + async ( + ...requests: Array<{ kind: string; id?: number; userId?: number }> + ): Promise => + requests.map((request): BatchResult => { + if (request.kind === 'thread') return { code: 200, data: thread } + if (request.kind === 'comments') return { code: 200, data: comments } + if (request.kind === 'comment') { + return { + code: 200, + data: comments.find((c) => c.id === request.id) ?? comments[0], + } } - if (request.kind === 'channel') return { code: 200, data: channel } - if (request.kind === 'sessionUser') return { code: 200, data: sessionUser } - if (request.kind === 'user' && request.userId) { - return { - code: 200, - data: users[request.userId] ?? { - id: request.userId, - name: `user:${request.userId}`, - }, + if (request.kind === 'channel') return { code: 200, data: channel } + if (request.kind === 'sessionUser') { + return { code: 200, data: sessionUser } } - } - throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`) - }), + if (request.kind === 'user' && request.userId) { + return { + code: 200, + data: users[request.userId] ?? { + id: request.userId, + name: `user:${request.userId}`, + }, + } + } + throw new Error(`Unexpected batch request: ${JSON.stringify(request)}`) + }), ), } } @@ -569,6 +578,70 @@ describe('thread view with failed batch response', () => { }) }) +describe('thread view with failed user batch response', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('throws a clear error when a batched user lookup fails', async () => { + const comments = [createComment(1, 1)] + const client = createClient({ + comments, + users: { + 1: { id: 1, name: 'Alice' }, + 2: { id: 2, name: 'Bob' }, + }, + }) + client.batch + .mockResolvedValueOnce([ + { code: 200, data: createThreadFixture(500) }, + { code: 200, data: comments }, + ]) + .mockResolvedValueOnce([ + { code: 200, data: { id: 100, name: 'General', workspaceId: 10 } }, + { code: 200, data: { id: 1, name: 'Alice' } }, + { code: 403, data: { errorString: 'User lookup failed' } }, + ]) + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + + await expect(program.parseAsync(['node', 'tw', 'thread', 'view', '500'])).rejects.toThrow( + 'Failed to fetch user 2: User lookup failed', + ) + }) + + it('renders the thread when a user lookup returns null data with a success code', async () => { + const comments = [createComment(1, 1), createComment(2, 2)] + const client = createClient({ + comments, + users: { 1: { id: 1, name: 'Alice' } }, + }) + client.batch + .mockResolvedValueOnce([ + { code: 200, data: createThreadFixture(500) }, + { code: 200, data: comments }, + ]) + .mockResolvedValueOnce([ + { code: 200, data: { id: 100, name: 'General', workspaceId: 10 } }, + { code: 200, data: { id: 1, name: 'Alice' } }, + { code: 200, data: null }, + ]) + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'thread', 'view', '500']) + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(output).toContain('Alice') + expect(output).toContain('user:2') + + consoleSpy.mockRestore() + }) +}) + describe('thread create', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/commands/thread/view.ts b/src/commands/thread/view.ts index 6ddd441..007de3b 100644 --- a/src/commands/thread/view.ts +++ b/src/commands/thread/view.ts @@ -1,6 +1,6 @@ import type { TwistApi } from '@doist/twist-sdk' import chalk from 'chalk' -import { assertBatchData, getTwistClient } from '../../lib/api.js' +import { assertBatchData, buildOptionalBatchNameMap, getTwistClient } from '../../lib/api.js' import { formatRelativeDate } from '../../lib/dates.js' import { renderMarkdown } from '../../lib/markdown.js' import type { PaginatedViewOptions } from '../../lib/options.js' @@ -29,8 +29,8 @@ async function viewSingleComment( const thread = assertBatchData(threadResponse, 'thread') const comment = assertBatchData(commentResponse, `comment ${commentId}`) - const userIds = new Set([thread.creator, comment.creator]) - const userCalls = [...userIds].map((id) => + const userIds = [...new Set([thread.creator, comment.creator])] + const userCalls = userIds.map((id) => client.workspaceUsers.getUserById( { workspaceId: thread.workspaceId, userId: id }, { batch: true }, @@ -42,9 +42,7 @@ async function viewSingleComment( ) const channel = assertBatchData(channelResponse, 'channel') - const userMap = new Map( - userResponses.filter((r) => r.data != null).map((r) => [r.data.id, r.data.name]), - ) + const userMap = buildOptionalBatchNameMap(userIds, userResponses, 'user') if (options.json) { const output = { @@ -135,12 +133,14 @@ export async function viewThread(ref: string, options: ViewOptions): Promise([ - thread.creator, - ...displayComments.map((c) => c.creator), - ...contextComments.map((c) => c.creator), - ]) - const userCalls = [...userIds].map((id) => + const userIds = [ + ...new Set([ + thread.creator, + ...displayComments.map((c) => c.creator), + ...contextComments.map((c) => c.creator), + ]), + ] + const userCalls = userIds.map((id) => client.workspaceUsers.getUserById( { workspaceId: thread.workspaceId, userId: id }, { batch: true }, @@ -152,9 +152,7 @@ export async function viewThread(ref: string, options: ViewOptions): Promise r.data != null).map((r) => [r.data.id, r.data.name]), - ) + const userMap = buildOptionalBatchNameMap(userIds, userResponses, 'user') if (options.json) { const output = { diff --git a/src/lib/api.ts b/src/lib/api.ts index cbf3247..a4c6bc4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,5 @@ import { + type BatchResponse, type Group, TwistApi, type User, @@ -344,17 +345,85 @@ export function clearUserCache(): void { sessionUserCache = null } +type BatchResult = Pick, 'code' | 'data'> + +function extractBatchErrorMessage(data: unknown): string | null { + if (!data || typeof data !== 'object') { + return null + } + + const record = data as Record + + if (typeof record.errorString === 'string' && record.errorString.trim()) { + return record.errorString.trim() + } + + if (typeof record.error_string === 'string' && record.error_string.trim()) { + return record.error_string.trim() + } + + if (Array.isArray(record.error)) { + const [, message] = record.error + if (typeof message === 'string' && message.trim()) { + return message.trim() + } + } + + return null +} + /** * Validates a batch response and returns the data, throwing on errors. * Also handles the case where the SDK fails to validate the response schema * (e.g. when the batch API wraps entities in a key like `{comment: {...}}`). * In that case, the raw transformed data is returned — check for expected fields. */ -export function assertBatchData(response: { code: number; data: T }, label: string): T { - if (response.code >= 400 || response.data == null) { - throw new CliError('BATCH_FAILED', `Failed to fetch ${label}.`) +export function assertBatchData(response: BatchResult, label: string): T { + if (response.code < 400 && response.data != null) { + return response.data } - return response.data + + const detail = extractBatchErrorMessage(response.data) + if (detail) { + throw new CliError('API_ERROR', `Failed to fetch ${label}: ${detail}`) + } + + throw new CliError('BATCH_FAILED', `Failed to fetch ${label}.`) +} + +export function buildBatchNameMap( + ids: readonly number[], + responses: readonly BatchResult[], + label: string, +): Map { + return new Map( + ids.map((id, index) => { + const entity = assertBatchData(responses[index], `${label} ${id}`) + return [entity.id, entity.name] as const + }), + ) +} + +/** + * Like `buildBatchNameMap` but skips entries whose `data` is null with a success + * code (e.g. a user that no longer exists). Real API errors (`code >= 400`) still + * throw via `assertBatchData`. Callers should provide a fallback for missing keys. + */ +export function buildOptionalBatchNameMap( + ids: readonly number[], + responses: readonly BatchResult[], + label: string, +): Map { + const entries: Array = [] + ids.forEach((id, index) => { + const response = responses[index] + if (response.code < 400 && response.data == null) { + return + } + const entity = assertBatchData(response, `${label} ${id}`) + entries.push([entity.id, entity.name] as const) + }) + return new Map(entries) } export type { Group, User, Workspace, WorkspaceUser }