pi-quiver 4.5.0 → 5.1.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.
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Name->ID cache for the slack extension: one JSON file per Slack workspace
3
+ * (keyed by team ID), holding channel and user name->ID maps. Sits on top of
4
+ * `lib/slack-core.ts`'s ApiCall/SlackError/SlackConfig - stays pi-free like
5
+ * its sibling core modules.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { dirname, isAbsolute, join } from "node:path";
10
+ import type { ApiCall, SlackConfig, UnresolvedMention } from "./slack-core.ts";
11
+ import { SlackError } from "./slack-core.ts";
12
+
13
+ export interface UserEntry {
14
+ id: string;
15
+ display_name: string;
16
+ real_name: string;
17
+ email?: string;
18
+ }
19
+
20
+ export interface SlackCacheFile {
21
+ team_id: string;
22
+ channels: Record<string, string>;
23
+ users: Record<string, UserEntry>;
24
+ refreshed_at: string;
25
+ /** Set only by refreshCache's full replacement; presence means this file once held a complete
26
+ * workspace listing, which is the only state where an alias match may be trusted from cache. */
27
+ snapshot_at?: string;
28
+ }
29
+
30
+ export interface CacheCtx {
31
+ apiCall: ApiCall;
32
+ token: string;
33
+ filePath: string;
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ const RAW_CHANNEL_ID = /^[CDG][A-Z0-9]{5,}$/;
38
+ const RAW_USER_ID = /^[UW][A-Z0-9]{5,}$/;
39
+
40
+ /** Hard cap on cursor-following pagination loops - a runaway/misbehaving API must fail loudly, never spin or write a partial cache. */
41
+ export const MAX_LIST_PAGES = 100;
42
+
43
+ /** Same per-OS convention as `lib/doc-to-md-core.ts`'s `cacheDir`, defined locally per spec. */
44
+ export function userCacheDir(platform: NodeJS.Platform, env: Record<string, string | undefined>, home: string): string {
45
+ if (platform === "win32") return join(env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "pi-quiver");
46
+ if (platform === "darwin") return join(home, "Library", "Caches", "pi-quiver");
47
+ return join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "pi-quiver");
48
+ }
49
+
50
+ export function cacheFilePath(
51
+ cfg: SlackConfig,
52
+ repoRoot: string,
53
+ teamId: string,
54
+ dirs?: { platform: NodeJS.Platform; env: Record<string, string | undefined>; home: string },
55
+ ): string {
56
+ if (cfg.cachePath) {
57
+ return isAbsolute(cfg.cachePath) ? cfg.cachePath : join(repoRoot, cfg.cachePath);
58
+ }
59
+ const dir = dirs
60
+ ? userCacheDir(dirs.platform, dirs.env, dirs.home)
61
+ : userCacheDir(process.platform, process.env, process.env.HOME ?? "");
62
+ return join(dir, `slack-${teamId}.json`);
63
+ }
64
+
65
+ const teamIdCache = new Map<string, string>();
66
+
67
+ export async function teamIdFor(token: string, apiCall: ApiCall, signal?: AbortSignal): Promise<string> {
68
+ const cached = teamIdCache.get(token);
69
+ if (cached !== undefined) return cached;
70
+ const data = await apiCall("auth.test", token, {}, { retry: false, signal });
71
+ const teamId = data.team_id as string;
72
+ teamIdCache.set(token, teamId);
73
+ return teamId;
74
+ }
75
+
76
+ export function assertSameTeam(a: string | undefined, b: string | undefined): void {
77
+ if (a !== undefined && b !== undefined && a !== b) {
78
+ throw new SlackError("team_mismatch", `User and bot tokens belong to different workspaces (${a} vs ${b}).`);
79
+ }
80
+ }
81
+
82
+ function readCacheFile(filePath: string): SlackCacheFile | undefined {
83
+ try {
84
+ return JSON.parse(readFileSync(filePath, "utf8")) as SlackCacheFile;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function emptyCache(teamId: string): SlackCacheFile {
91
+ return { team_id: teamId, channels: {}, users: {}, refreshed_at: new Date().toISOString() };
92
+ }
93
+
94
+ function atomicWrite(filePath: string, data: SlackCacheFile): void {
95
+ mkdirSync(dirname(filePath), { recursive: true });
96
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
97
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
98
+ renameSync(tmpPath, filePath);
99
+ }
100
+
101
+ function mergeAndWrite(filePath: string, teamId: string, patch: (base: SlackCacheFile) => void): void {
102
+ const base = existsSync(filePath) ? (readCacheFile(filePath) ?? emptyCache(teamId)) : emptyCache(teamId);
103
+ patch(base);
104
+ base.refreshed_at = new Date().toISOString();
105
+ atomicWrite(filePath, base);
106
+ }
107
+
108
+ function stripPrefix(input: string): string {
109
+ return input.startsWith("#") || input.startsWith("@") ? input.slice(1) : input;
110
+ }
111
+
112
+ function toUserEntry(u: SlackUser): UserEntry {
113
+ const email = u.profile?.email;
114
+ return {
115
+ id: u.id,
116
+ display_name: u.profile?.display_name ?? "",
117
+ real_name: u.profile?.real_name ?? u.real_name ?? "",
118
+ ...(email ? { email } : {}),
119
+ };
120
+ }
121
+
122
+ export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
123
+ if (RAW_CHANNEL_ID.test(input)) return input;
124
+ if (input.startsWith("@")) {
125
+ throw new SlackError(
126
+ "invalid_channel",
127
+ `"${input}" looks like a user name, which is not accepted in a channel position (opening a DM is out of scope).`,
128
+ );
129
+ }
130
+ const name = stripPrefix(input);
131
+
132
+ const cached = readCacheFile(ctx.filePath);
133
+ const hit = cached?.channels[name];
134
+ if (hit !== undefined) return hit;
135
+
136
+ let cursor = "";
137
+ for (let page = 1; ; page++) {
138
+ if (page > MAX_LIST_PAGES) {
139
+ throw new SlackError(
140
+ "pagination_overflow",
141
+ `resolveChannel: conversations.list did not terminate within ${MAX_LIST_PAGES} pages.`,
142
+ );
143
+ }
144
+ const data = await ctx.apiCall(
145
+ "conversations.list",
146
+ ctx.token,
147
+ { limit: 1000, types: "public_channel,private_channel", ...(cursor ? { cursor } : {}) },
148
+ { retry: false, signal: ctx.signal },
149
+ );
150
+ const channels = (data.channels as { id: string; name: string }[]) ?? [];
151
+ const match = channels.find((c) => c.name === name);
152
+ if (match) {
153
+ mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
154
+ base.channels[name] = match.id;
155
+ });
156
+ return match.id;
157
+ }
158
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
159
+ const nextCursor = meta?.next_cursor ?? "";
160
+ // Slack can echo the same cursor repeatedly under throttling - treat a repeated non-empty
161
+ // cursor as exhaustion rather than spinning until MAX_LIST_PAGES.
162
+ if (nextCursor !== "" && nextCursor === cursor) break;
163
+ cursor = nextCursor;
164
+ if (!cursor) break;
165
+ }
166
+
167
+ throw new SlackError("name_not_found", `No channel named "${name}" was found in the workspace.`);
168
+ }
169
+
170
+ interface SlackUser {
171
+ id: string;
172
+ name: string;
173
+ profile?: { display_name?: string; real_name?: string; email?: string };
174
+ real_name?: string;
175
+ }
176
+
177
+ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string> {
178
+ if (RAW_USER_ID.test(input)) return input;
179
+ if (input.startsWith("#")) {
180
+ throw new SlackError(
181
+ "invalid_user",
182
+ `"${input}" looks like a channel name, which is not accepted in a user position.`,
183
+ );
184
+ }
185
+ const name = stripPrefix(input);
186
+
187
+ const cached = readCacheFile(ctx.filePath);
188
+ if (cached) {
189
+ const byUsername = cached.users[name];
190
+ if (byUsername) return byUsername.id;
191
+
192
+ const displayMatches = Object.values(cached.users).filter((u) => u.display_name === name);
193
+ if (displayMatches.length === 1) return displayMatches[0].id;
194
+ if (displayMatches.length > 1) {
195
+ throw new SlackError(
196
+ "ambiguous_user",
197
+ `Multiple users have display name "${name}": ${displayMatches.map((u) => u.id).join(", ")}.`,
198
+ );
199
+ }
200
+
201
+ const realMatches = Object.values(cached.users).filter((u) => u.real_name === name);
202
+ if (realMatches.length === 1) return realMatches[0].id;
203
+ if (realMatches.length > 1) {
204
+ throw new SlackError(
205
+ "ambiguous_user",
206
+ `Multiple users have real name "${name}": ${realMatches.map((u) => u.id).join(", ")}.`,
207
+ );
208
+ }
209
+ }
210
+
211
+ let cursor = "";
212
+ const displayCandidates: SlackUser[] = [];
213
+ const realCandidates: SlackUser[] = [];
214
+ for (let page = 1; ; page++) {
215
+ if (page > MAX_LIST_PAGES) {
216
+ throw new SlackError(
217
+ "pagination_overflow",
218
+ `resolveUser: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
219
+ );
220
+ }
221
+ const data = await ctx.apiCall(
222
+ "users.list",
223
+ ctx.token,
224
+ { limit: 1000, ...(cursor ? { cursor } : {}) },
225
+ { retry: false, signal: ctx.signal },
226
+ );
227
+ const members = (data.members as SlackUser[]) ?? [];
228
+ for (const u of members) {
229
+ if (u.name === name) {
230
+ mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
231
+ base.users[name] = toUserEntry(u);
232
+ });
233
+ return u.id;
234
+ }
235
+ if (u.profile?.display_name === name) displayCandidates.push(u);
236
+ if ((u.profile?.real_name ?? u.real_name) === name) realCandidates.push(u);
237
+ }
238
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
239
+ const nextCursor = meta?.next_cursor ?? "";
240
+ // Slack can echo the same cursor repeatedly under throttling - treat a repeated non-empty
241
+ // cursor as exhaustion rather than spinning until MAX_LIST_PAGES.
242
+ if (nextCursor !== "" && nextCursor === cursor) break;
243
+ cursor = nextCursor;
244
+ if (!cursor) break;
245
+ }
246
+
247
+ if (displayCandidates.length === 1) {
248
+ const u = displayCandidates[0];
249
+ mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
250
+ base.users[u.name] = toUserEntry(u);
251
+ });
252
+ return u.id;
253
+ }
254
+ if (displayCandidates.length > 1) {
255
+ throw new SlackError(
256
+ "ambiguous_user",
257
+ `Multiple users have display name "${name}": ${displayCandidates.map((u) => u.id).join(", ")}.`,
258
+ );
259
+ }
260
+
261
+ if (realCandidates.length === 1) {
262
+ const u = realCandidates[0];
263
+ mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
264
+ base.users[u.name] = toUserEntry(u);
265
+ });
266
+ return u.id;
267
+ }
268
+ if (realCandidates.length > 1) {
269
+ throw new SlackError(
270
+ "ambiguous_user",
271
+ `Multiple users have real name "${name}": ${realCandidates.map((u) => u.id).join(", ")}.`,
272
+ );
273
+ }
274
+
275
+ throw new SlackError("name_not_found", `No user named "${name}" was found in the workspace.`);
276
+ }
277
+
278
+ const MENTION_DENY = new Set(["here", "channel", "everyone"]);
279
+ const MENTION_BOUNDARY = new Set([" ", "\t", "\n", "\r", "(", "[", "*", "_", '"', "'"]);
280
+ // `_` is trimmed here even though it is also a boundary character: it is a legal username
281
+ // character too, so an italic-wrapped mention like `_@alice_` is otherwise unreachable - the
282
+ // trailing `_` must be stripped for the candidate to resolve.
283
+ const MENTION_TRAILING = /[.,;:!?)_]+$/;
284
+ const MENTION_SCAN = /(\\?)@([A-Za-z0-9._-]+)/g;
285
+
286
+ function isBoundary(text: string, atIndex: number): boolean {
287
+ if (atIndex === 0) return true;
288
+ return MENTION_BOUNDARY.has(text[atIndex - 1]);
289
+ }
290
+
291
+ /**
292
+ * Substitutes `@name` -> `<@U...>` across every field a mutation will actually send.
293
+ * Never throws for an unresolvable name: not-found, ambiguous, and a failed live lookup all
294
+ * leave the text literal and report it, because a mention is prose, not an addressed parameter.
295
+ */
296
+ export async function resolveMentions(
297
+ fields: { field: "text" | "thread_body"; value: string }[],
298
+ ctx: CacheCtx,
299
+ ): Promise<{
300
+ values: string[];
301
+ unresolved: UnresolvedMention[];
302
+ lookupError?: string;
303
+ }> {
304
+ interface Candidate {
305
+ field: "text" | "thread_body";
306
+ start: number;
307
+ end: number;
308
+ escaped: boolean;
309
+ /** [name, endOffsetForThatName] pairs, trimmed variant first so it wins the lookup race. */
310
+ lookups: [string, number][];
311
+ }
312
+
313
+ const perField: Candidate[][] = fields.map(() => []);
314
+ const wanted = new Set<string>();
315
+
316
+ fields.forEach((f, fi) => {
317
+ for (const m of f.value.matchAll(MENTION_SCAN)) {
318
+ const escaped = m[1] === "\\";
319
+ const at = m.index + m[1].length;
320
+ if (escaped) {
321
+ if (!isBoundary(f.value, m.index)) continue;
322
+ } else if (!isBoundary(f.value, at)) continue;
323
+
324
+ const raw = m[2];
325
+ const fullEnd = m.index + m[0].length;
326
+ const trimmed = raw.replace(MENTION_TRAILING, "");
327
+ // Check the deny list against both forms: `_@channel_`/`@here.` still carry the
328
+ // trailing punctuation stripped below, so a raw-only check misses them.
329
+ if (MENTION_DENY.has(raw) || MENTION_DENY.has(trimmed)) continue;
330
+ // A name that is entirely trailing-punctuation (e.g. `@...`) trims to "" - not a
331
+ // real candidate, so skip it rather than looking up an empty username.
332
+ if (trimmed === "") continue;
333
+ const trimmedEnd = fullEnd - (raw.length - trimmed.length);
334
+ const lookups: [string, number][] =
335
+ trimmed === raw ? [[raw, fullEnd]] : [[trimmed, trimmedEnd], [raw, fullEnd]];
336
+ perField[fi].push({ field: f.field, start: m.index, end: fullEnd, escaped, lookups });
337
+ if (!escaped) for (const [name] of lookups) wanted.add(name);
338
+ }
339
+ });
340
+
341
+ const cached = readCacheFile(ctx.filePath);
342
+ const resolved = new Map<string, string>();
343
+ const aliasTrusted = cached?.snapshot_at !== undefined;
344
+ // Names an aliasTrusted FULL snapshot already proved ambiguous: a live users.list call
345
+ // cannot un-ambiguate them, so they must not fall into `outstanding`.
346
+ const conclusivelyAmbiguous = new Set<string>();
347
+
348
+ if (cached) {
349
+ for (const name of wanted) {
350
+ const byUsername = cached.users[name];
351
+ if (byUsername) {
352
+ resolved.set(name, byUsername.id);
353
+ continue;
354
+ }
355
+ if (!aliasTrusted) continue;
356
+ const entries = Object.values(cached.users);
357
+ const display = entries.filter((u) => u.display_name === name);
358
+ if (display.length === 1) {
359
+ resolved.set(name, display[0].id);
360
+ continue;
361
+ }
362
+ if (display.length > 1) {
363
+ conclusivelyAmbiguous.add(name);
364
+ continue;
365
+ }
366
+ const real = entries.filter((u) => u.real_name === name);
367
+ if (real.length === 1) resolved.set(name, real[0].id);
368
+ else if (real.length > 1) conclusivelyAmbiguous.add(name);
369
+ }
370
+ }
371
+
372
+ const outstanding = [...wanted].filter((n) => !resolved.has(n) && !conclusivelyAmbiguous.has(n));
373
+ let lookupError: string | undefined;
374
+
375
+ if (outstanding.length > 0) {
376
+ try {
377
+ const found = new Map<string, SlackUser>();
378
+ const aliasHits = new Map<string, SlackUser[]>();
379
+ let cursor = "";
380
+ for (let page = 1; ; page++) {
381
+ if (page > MAX_LIST_PAGES) {
382
+ throw new SlackError(
383
+ "pagination_overflow",
384
+ `resolveMentions: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
385
+ );
386
+ }
387
+ const data = await ctx.apiCall(
388
+ "users.list",
389
+ ctx.token,
390
+ { limit: 1000, ...(cursor ? { cursor } : {}) },
391
+ { retry: false, signal: ctx.signal },
392
+ );
393
+ for (const u of (data.members as SlackUser[]) ?? []) {
394
+ for (const name of outstanding) {
395
+ if (u.name === name) found.set(name, u);
396
+ else if (u.profile?.display_name === name || (u.profile?.real_name ?? u.real_name) === name) {
397
+ aliasHits.set(name, [...(aliasHits.get(name) ?? []), u]);
398
+ }
399
+ }
400
+ }
401
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
402
+ const nextCursor = meta?.next_cursor ?? "";
403
+ if (nextCursor !== "" && nextCursor === cursor) break;
404
+ cursor = nextCursor;
405
+ if (!cursor) break;
406
+ }
407
+
408
+ const writes: SlackUser[] = [];
409
+ for (const name of outstanding) {
410
+ const exact = found.get(name);
411
+ const aliases = aliasHits.get(name) ?? [];
412
+ const pick = exact ?? (aliases.length === 1 ? aliases[0] : undefined);
413
+ if (!pick) continue;
414
+ resolved.set(name, pick.id);
415
+ writes.push(pick);
416
+ }
417
+ if (writes.length > 0) {
418
+ const teamId = cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal));
419
+ mergeAndWrite(ctx.filePath, teamId, (base) => {
420
+ for (const u of writes) base.users[u.name] = toUserEntry(u);
421
+ });
422
+ }
423
+ } catch (err) {
424
+ lookupError = err instanceof Error ? err.message : String(err);
425
+ }
426
+ }
427
+
428
+ const unresolved: UnresolvedMention[] = [];
429
+ const seenUnresolved = new Set<string>();
430
+ const values = fields.map((f, fi) => {
431
+ let out = "";
432
+ let cursor = 0;
433
+ for (const c of perField[fi]) {
434
+ out += f.value.slice(cursor, c.start);
435
+ const literal = f.value.slice(c.start, c.end);
436
+ if (c.escaped) {
437
+ out += literal.slice(1);
438
+ cursor = c.end;
439
+ } else {
440
+ const hit = c.lookups.find(([n]) => resolved.has(n));
441
+ if (hit !== undefined) {
442
+ out += `<@${resolved.get(hit[0])}>`;
443
+ cursor = hit[1];
444
+ } else {
445
+ out += literal;
446
+ const name = `@${c.lookups[0][0]}`;
447
+ // Documented contract (doc/slack.md): unresolvedMentions is deduplicated by
448
+ // (field, name), so "cc @bob @bob" reports @bob once, not once per occurrence.
449
+ const key = `${c.field}\u0000${name}`;
450
+ if (!seenUnresolved.has(key)) {
451
+ seenUnresolved.add(key);
452
+ unresolved.push({ field: c.field, name });
453
+ }
454
+ cursor = c.end;
455
+ }
456
+ }
457
+ }
458
+ return out + f.value.slice(cursor);
459
+ });
460
+
461
+ return { values, unresolved, ...(lookupError !== undefined ? { lookupError } : {}) };
462
+ }
463
+
464
+ /**
465
+ * Full snapshot replace, deliberately asymmetric with mergeAndWrite: refresh's job is to
466
+ * atomically overwrite the whole file, so a concurrent mergeAndWrite write racing this one may
467
+ * be clobbered (last-writer-wins). Accepted per spec (doc/specs/2026-08-29-gh-7-slack-extension.md
468
+ * Cache section) - a clobbered merge self-heals on the next cache miss.
469
+ */
470
+ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number; emails: number }> {
471
+ const teamId = await teamIdFor(ctx.token, ctx.apiCall, ctx.signal);
472
+ const channels: Record<string, string> = {};
473
+ const users: Record<string, UserEntry> = {};
474
+
475
+ let cursor = "";
476
+ for (let page = 1; ; page++) {
477
+ if (page > MAX_LIST_PAGES) {
478
+ throw new SlackError(
479
+ "pagination_overflow",
480
+ `refreshCache: conversations.list did not terminate within ${MAX_LIST_PAGES} pages.`,
481
+ );
482
+ }
483
+ const data = await ctx.apiCall(
484
+ "conversations.list",
485
+ ctx.token,
486
+ { limit: 1000, types: "public_channel,private_channel", ...(cursor ? { cursor } : {}) },
487
+ { retry: false, signal: ctx.signal },
488
+ );
489
+ const list = (data.channels as { id: string; name: string }[]) ?? [];
490
+ for (const c of list) channels[c.name] = c.id;
491
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
492
+ const nextCursor = meta?.next_cursor ?? "";
493
+ // A repeated non-empty cursor never terminates via has_more logic - refresh must not write a
494
+ // partial cache, so this is a hard overflow, not silent exhaustion (unlike the scan loops).
495
+ if (nextCursor !== "" && nextCursor === cursor) {
496
+ throw new SlackError(
497
+ "pagination_overflow",
498
+ `refreshCache: conversations.list returned the same cursor twice (${nextCursor}); aborting without writing a partial cache.`,
499
+ );
500
+ }
501
+ cursor = nextCursor;
502
+ if (!cursor) break;
503
+ }
504
+
505
+ cursor = "";
506
+ for (let page = 1; ; page++) {
507
+ if (page > MAX_LIST_PAGES) {
508
+ throw new SlackError(
509
+ "pagination_overflow",
510
+ `refreshCache: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
511
+ );
512
+ }
513
+ const data = await ctx.apiCall("users.list", ctx.token, { limit: 1000, ...(cursor ? { cursor } : {}) }, { retry: false, signal: ctx.signal });
514
+ const members = (data.members as SlackUser[]) ?? [];
515
+ for (const u of members) {
516
+ users[u.name] = toUserEntry(u);
517
+ }
518
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
519
+ const nextCursor = meta?.next_cursor ?? "";
520
+ if (nextCursor !== "" && nextCursor === cursor) {
521
+ throw new SlackError(
522
+ "pagination_overflow",
523
+ `refreshCache: users.list returned the same cursor twice (${nextCursor}); aborting without writing a partial cache.`,
524
+ );
525
+ }
526
+ cursor = nextCursor;
527
+ if (!cursor) break;
528
+ }
529
+
530
+ const snapshotAt = new Date().toISOString();
531
+ atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: snapshotAt, snapshot_at: snapshotAt });
532
+ return {
533
+ channels: Object.keys(channels).length,
534
+ users: Object.keys(users).length,
535
+ emails: Object.values(users).filter((u) => u.email !== undefined).length,
536
+ };
537
+ }