1
34import { Worker, type Job } from "bullmq";
35import { withJobOtel } from "../../lib/job-otel.js";
36import type { Redis } from "ioredis";
37import { z } from "zod";
38import type { PrismaClient } from "@prisma/client";
39import { ApiError } from "@factory/shared/errors";
40import {
41 CONTENT_INDEX_ALIAS,
42 CHUNK_DOC_TYPE,
43 contentMapping,
44 vectorDimensionMatches,
45 type IndexDocInput,
46 type SearchClient,
47 type SearchType,
48} from "@factory/search";
49import { QUEUES, enqueue } from "../../queues.js";
50import { getLogger } from "../../logger.js";
51import type { WorkerEnv } from "../../config.js";
52import { StreamConsumer, type StreamEntry } from "../../lib/redis-stream.js";
53import { tryParseEvent, SEARCH_INDEX_EVENTS } from "../../events.js";
54import {
55 buildPageDoc,
56 buildBlockDocs,
57 buildDatabaseDoc,
58 buildRecordDoc,
59 buildCommentDoc,
60 buildFileDoc,
61 buildUserDoc,
62 contentHash,
63 upsertMirror,
64 deleteMirror,
65 mirrorRefId,
66 docIdFor,
67 expandChunkDocs,
68 type IndexerDeps,
69} from "./builders.js";
70
71const log = getLogger("search-index");
72
73const BATCH = 500;
74const DLQ_STREAM = "search-index:dlq";
75const INDEX_VERSION = "v1";
76
77export interface SearchIndexOps {
78
79 search: SearchClient;
80 prisma: PrismaClient;
81
82 connection: Redis;
83 env: WorkerEnv;
84
91 enqueueEmbed?: (job: { workspaceId: string; docType: "page" | "record"; refId: string }) => Promise<unknown> | unknown;
92}
93
94const deps = (ops: SearchIndexOps): IndexerDeps => ({ prisma: ops.prisma });
95
96
97
98
99
100const contentIndexName = () => `factory-content-${INDEX_VERSION}`;
101
102
108export async function ensureContentIndex(ops: SearchIndexOps): Promise<{ created: boolean }> {
109 const { search, env } = ops;
110 const mapping = contentMapping({
111 dimension: env.SEARCH_VECTOR_DIMENSION,
112 m: env.SEARCH_KNN_M,
113 efConstruction: env.SEARCH_KNN_EF_CONSTRUCTION,
114 });
115 const name = contentIndexName();
116 const exists = await search.indexExists(name).catch(() => false);
117 if (!exists) {
118
119
120
121
122
123
124
125
126 if (!(await search.isAlias(CONTENT_INDEX_ALIAS).catch(() => false))) {
127 const bareReal = await search.indexExists(CONTENT_INDEX_ALIAS).catch(() => false);
128 if (bareReal) {
129 log.warn(
130 { index: CONTENT_INDEX_ALIAS },
131 "content alias name is a real (mapping-less) index — removing to rebuild versioned index + alias"
132 );
133 await search.deleteIndex(CONTENT_INDEX_ALIAS).catch((err: unknown) => {
134 log.error({ err: (err as Error).message }, "failed to delete bare content index — manual cleanup required");
135 throw err;
136 });
137 }
138 }
139 const created = await search.createIndexIfMissing(name, mapping);
140 if (created) await search.createAlias(name, CONTENT_INDEX_ALIAS);
141 log.info(
142 { index: name, alias: CONTENT_INDEX_ALIAS, dimension: env.SEARCH_VECTOR_DIMENSION },
143 "content index ensured"
144 );
145 return { created };
146 }
147 const liveDim = await search.getVectorDimension(name).catch(() => null);
148 if (liveDim !== null && !vectorDimensionMatches({ mappings: { properties: { vector: { dimension: liveDim } } } }, env.SEARCH_VECTOR_DIMENSION)) {
149 log.warn(
150 { index: name, liveDim, wanted: env.SEARCH_VECTOR_DIMENSION },
151 "content index dimension mismatch — alias rollover required (scripts/opensearch-reindex.ts)"
152 );
153 }
154 return { created: false };
155}
156
157
167let contentIndexEnsured = false;
168async function ensureContentIndexOnce(ops: SearchIndexOps): Promise<void> {
169 if (contentIndexEnsured) return;
170 await ensureContentIndex(ops);
171 contentIndexEnsured = true;
172}
173
174
175
176
177
178
192export async function indexDocs(ops: SearchIndexOps, docs: IndexDocInput[]): Promise<{ indexed: number }> {
193 await ensureContentIndexOnce(ops);
194 const chunkableParents = docs
195 .filter((d) => d.type === "page" || d.type === "record")
196 .map((d) => d.docId);
197 if (chunkableParents.length > 0) {
198 await ops.search.deleteChunksOf(chunkableParents);
199 }
200 const expanded = docs.flatMap((d) => expandChunkDocs(d));
201 let indexed = 0;
202 for (let i = 0; i < expanded.length; i += BATCH) {
203 const chunk = expanded.slice(i, i + BATCH);
204 const res = await ops.search.indexDocs(chunk);
205 for (const doc of chunk) {
206 if (doc.type !== CHUNK_DOC_TYPE) await upsertMirror(ops.prisma, doc);
207 }
208 indexed += res.indexed;
209 if (i + BATCH < expanded.length) log.info({ batch: i / BATCH + 1 }, "index batch");
210 }
211
212
213
214
215
216
217
218
219 if (ops.enqueueEmbed) {
220 for (const d of docs) {
221 if (d.type !== "page" && d.type !== "record") continue;
222 const refId = d.docId.split(":")[1] ?? d.docId;
223 try {
224 await ops.enqueueEmbed({ workspaceId: d.workspaceId, docType: d.type, refId });
225 } catch (err) {
226 log.warn(
227 { err: (err as Error).message, docId: d.docId },
228 "embed:chunks enqueue failed (nightly re-embed backstops)",
229 );
230 }
231 }
232 }
233 return { indexed };
234}
235
236
241export async function removeDoc(
242 ops: SearchIndexOps,
243 type: SearchType,
244 refId: string,
245 workspaceId?: string
246): Promise<void> {
247 const docId = type === "user" && workspaceId ? `user:${workspaceId}:${refId}` : `${type}:${refId}`;
248 await ensureContentIndexOnce(ops);
249 try {
250 await ops.search.deleteDoc(docId);
251 } catch (err) {
252 if (!(err instanceof ApiError && err.status === 404)) throw err;
253 }
254 if (type === "page" || type === "record") {
255 try {
256 await ops.search.deleteChunksOf(docId);
257 } catch (err) {
258 if (!(err instanceof ApiError && err.status === 404)) throw err;
259 }
260 }
261 await deleteMirror(ops.prisma, type, refId, workspaceId);
262}
263
264
265
266
267
268
276async function pageDocs(
277 ops: SearchIndexOps,
278 pageId: string,
279 workspaceId?: string
280): Promise<{ docs: IndexDocInput[]; deleteIds: string[] }> {
281 const d = deps(ops);
282 const [pageDoc, blockDocs] = await Promise.all([buildPageDoc(d, pageId), buildBlockDocs(d, pageId)]);
283 if (pageDoc || blockDocs.length > 0) {
284 return { docs: [pageDoc, ...blockDocs].filter((x): x is IndexDocInput => x !== null), deleteIds: [] };
285 }
286
287 const orphanBlocks = await ops.prisma.searchDocument.findMany({
288 where: { ...(workspaceId ? { workspaceId } : {}), type: "block", metadata: { path: ["pageId"], equals: pageId } },
289 select: { refId: true },
290 });
291 const deleteIds = [`page:${pageId}`, ...orphanBlocks.map((b) => `block:${b.refId}`)];
292 return { docs: [], deleteIds };
293}
294
295
301export async function handleSearchEvent(
302 ops: SearchIndexOps,
303 envelope: { event: string; payload: Record<string, unknown> }
304): Promise<void> {
305 if (!SEARCH_INDEX_EVENTS.includes(envelope.event as (typeof SEARCH_INDEX_EVENTS)[number])) {
306 return;
307 }
308 const p = envelope.payload;
309 const t0 = Date.now();
310 switch (envelope.event) {
311 case "page.created":
312 case "page.updated":
313 case "page.shared": {
314 const ws = str(p.workspaceId);
315 const pageId = str(p.pageId ?? p.id);
316 if (!pageId) return;
317 const { docs, deleteIds } = await pageDocs(ops, pageId, ws || undefined);
318 if (deleteIds.length > 0) {
319
320
321
322 for (const id of deleteIds) {
323 const [type, ...rest] = id.split(":");
324 await removeDoc(ops, type as SearchType, rest.join(":"), ws || undefined);
325 }
326 log.info({ pageId, removed: deleteIds.length, ms: Date.now() - t0 }, "page removed from search (trashed/purged)");
327 break;
328 }
329 await indexDocs(ops, docs);
330 log.info({ event: envelope.event, pageId, docs: docs.length, ms: Date.now() - t0 }, "page indexed");
331 break;
332 }
333 case "page.deleted": {
334
335
336
337
338
339 const ws = str(p.workspaceId);
340 const rootPage = str(p.pageId ?? p.id);
341 const pageIds: string[] = Array.isArray(p.pageIds)
342 ? (p.pageIds as unknown[]).filter((x): x is string => typeof x === "string")
343 : [];
344 const all = [...new Set([rootPage, ...pageIds].filter(Boolean))];
345 for (const pageId of all) {
346 const { deleteIds } = await pageDocs(ops, pageId, ws || undefined);
347 for (const id of deleteIds) {
348 const [type, ...rest] = id.split(":");
349 await removeDoc(ops, type as SearchType, rest.join(":"), ws || undefined);
350 }
351 }
352 log.info({ pageIds: all, permanent: p.permanent === true, ms: Date.now() - t0 }, "page(s) removed from search");
353 break;
354 }
355 case "database.created":
356 case "database.updated": {
357 const databaseId = str(p.databaseId ?? p.id);
358 if (!databaseId) return;
359 const doc = await buildDatabaseDoc(deps(ops), databaseId);
360 if (doc) {
361 await indexDocs(ops, [doc]);
362 log.info({ databaseId, ms: Date.now() - t0 }, "database indexed");
363 } else {
364 log.warn({ databaseId }, "database source row missing — poison, ack (reconcile backstops)");
365 }
366 break;
367 }
368 case "record.created":
369 case "record.updated": {
370 const recordId = str(p.recordId ?? p.id);
371 if (!recordId) return;
372 const doc = await buildRecordDoc(deps(ops), recordId);
373 if (doc) {
374 await indexDocs(ops, [doc]);
375 log.info({ recordId, ms: Date.now() - t0 }, "record indexed");
376 } else {
377 log.warn({ recordId }, "record source row missing — poison, ack (reconcile backstops)");
378 }
379 break;
380 }
381 case "comment.created": {
382 const commentId = str(p.commentId ?? p.id);
383 if (!commentId) return;
384 const doc = await buildCommentDoc(deps(ops), commentId);
385 if (doc) {
386 await indexDocs(ops, [doc]);
387 log.info({ commentId, ms: Date.now() - t0 }, "comment indexed");
388 } else {
389 log.warn({ commentId }, "comment source row missing — poison, ack (reconcile backstops)");
390 }
391 break;
392 }
393 case "comment.deleted": {
394
395 const commentId = str(p.commentId ?? p.id);
396 if (!commentId) return;
397 await removeDoc(ops, "comment", commentId, str(p.workspaceId) || undefined);
398 log.info({ commentId, ms: Date.now() - t0 }, "comment deleted from search");
399 break;
400 }
401 case "file.uploaded":
402 case "file.finalized":
403 case "file.updated": {
404
405
406
407
408
409 const fileId = str(p.fileId ?? p.id);
410 if (!fileId) return;
411 const doc = await buildFileDoc(deps(ops), fileId);
412 if (doc) {
413 await indexDocs(ops, [doc]);
414 log.info({ fileId, ms: Date.now() - t0 }, "file indexed (mime/size may converge via resync)");
415 } else {
416 log.warn({ fileId }, "file source row missing — poison, ack (reconcile backstops)");
417 }
418 break;
419 }
420 case "file.deleted": {
421
422
423
424
425
426
427
428
429 const fileId = str(p.fileId ?? p.id);
430 if (!fileId) return;
431 await removeDoc(ops, "file", fileId, str(p.workspaceId) || undefined);
432 log.info({ fileId, ms: Date.now() - t0 }, "file removed from search (trashed)");
433 break;
434 }
435 case "permission.changed": {
436
437
438
439
440 const ws = str(p.workspaceId);
441 if (!ws) return;
442 await enqueue(ops.connection, QUEUES.maintenance, { type: "workspace-resync", workspaceId: ws });
443 log.info({ workspaceId: ws, ms: Date.now() - t0 }, "permission changed → workspace resync enqueued");
444 break;
445 }
446 case "user.register":
447 case "user.login": {
448 const userId = str(p.userId ?? p.id);
449 if (!userId) return;
450 const ws = str(p.workspaceId);
451 if (!ws) {
452 log.warn({ userId }, "user event without workspaceId — nothing to stamp (reconcile covers)");
453 return;
454 }
455 const doc = await buildUserDoc(deps(ops), userId, ws);
456 if (doc) await indexDocs(ops, [doc]);
457 break;
458 }
459 case "workspace.created": {
460 const ws = str(p.workspaceId);
461 if (!ws) return;
462 await runReindex(ops, ws);
463 log.info({ workspaceId: ws, ms: Date.now() - t0 }, "workspace created → initial index");
464 break;
465 }
466 default:
467 return;
468 }
469}
470
471export function createSearchIndexConsumer(opts: {
472 redis: Redis;
473 ops: SearchIndexOps;
474 consumerId: string;
475}): StreamConsumer {
476 const { redis, ops, consumerId } = opts;
477 return new StreamConsumer(
478 redis,
479 "factory:events",
480 "worker-search-index",
481 consumerId,
482 async (entry: StreamEntry) => {
483 const parsed = tryParseEvent(entry);
484 if (!parsed) return;
485 try {
486 await handleSearchEvent(ops, parsed.envelope);
487 } catch (err) {
488
489
490 log.error({ err: (err as Error).message, event: parsed.envelope.event, id: entry.id }, "search index handler failed — entry pending");
491 throw err;
492 }
493 }
494 );
495}
496
497
498
499
500
501export interface ReindexCounts {
502 workspaces: number;
503 docs: number;
504 orphansRemoved: number;
505}
506
507
513async function collectWorkspaceDocs(ops: SearchIndexOps, ws: string): Promise<IndexDocInput[]> {
514 const { prisma } = ops;
515 const d = deps(ops);
516 const [pages, databases, records, comments, files, members] = await Promise.all([
517 prisma.page.findMany({ where: { workspaceId: ws, deletedAt: null }, select: { id: true } }),
518 prisma.database.findMany({ where: { workspaceId: ws }, select: { id: true } }),
519 prisma.databaseRecord.findMany({ where: { workspaceId: ws }, select: { id: true } }),
520 prisma.comment.findMany({ where: { workspaceId: ws, pageId: { not: null } }, select: { id: true } }),
521 prisma.file.findMany({ where: { workspaceId: ws, deletedAt: null }, select: { id: true } }),
522 prisma.workspaceMember.findMany({ where: { workspaceId: ws }, select: { userId: true } }),
523 ]);
524
525 const docs: IndexDocInput[] = [];
526 for (const pg of pages) docs.push(...(await pageDocs(ops, pg.id, ws).then((r) => r.docs)));
527 for (const db of databases) {
528 const doc = await buildDatabaseDoc(d, db.id);
529 if (doc) docs.push(doc);
530 }
531 for (const r of records) {
532 const doc = await buildRecordDoc(d, r.id);
533 if (doc) docs.push(doc);
534 }
535 for (const c of comments) {
536 const doc = await buildCommentDoc(d, c.id);
537 if (doc) docs.push(doc);
538 }
539 for (const f of files) {
540 const doc = await buildFileDoc(d, f.id);
541 if (doc) docs.push(doc);
542 }
543
544 for (const m of members) {
545 const doc = await buildUserDoc(d, m.userId, ws);
546 if (doc) docs.push(doc);
547 }
548 return docs;
549}
550
551export async function runReindex(ops: SearchIndexOps, workspaceId?: string, orgId?: string): Promise<ReindexCounts> {
552 await ensureContentIndexOnce(ops);
553 const { prisma } = ops;
554
555
556
557 const workspaces = workspaceId
558 ? [workspaceId]
559 : (
560 await prisma.workspace.findMany({
561 where: orgId ? { organizationId: orgId } : {},
562 select: { id: true }
563 })
564 ).map((w) => w.id);
565 const counts: ReindexCounts = { workspaces: 0, docs: 0, orphansRemoved: 0 };
566
567 for (const ws of workspaces) {
568 const docs = await collectWorkspaceDocs(ops, ws);
569 await indexDocs(ops, docs);
570
571
572
573
574 const live = new Set(docs.map((doc) => `${doc.type}:${mirrorRefId(doc)}`));
575 const mirror = await prisma.searchDocument.findMany({
576 where: { workspaceId: ws },
577 select: { type: true, refId: true },
578 });
579 for (const row of mirror) {
580 if (live.has(`${row.type}:${row.refId}`)) continue;
581 const refId = row.type === "user" ? row.refId.split(":").pop() ?? row.refId : row.refId;
582 await removeDoc(ops, row.type as SearchType, refId, ws);
583 counts.orphansRemoved++;
584 }
585
586 counts.docs += docs.length;
587 counts.workspaces++;
588 log.info({ workspaceId: ws, docs: docs.length, orphans: counts.orphansRemoved }, "reindex workspace done");
589 }
590 log.info(counts, "reindex complete");
591 return counts;
592}
593
594
595
596
597
598
599export interface ReconcileResult {
600 workspaces: number;
601 checked: number;
602 repaired: number;
603 removed: number;
604}
605
606
607async function docFromMirrorRow(
608 ops: SearchIndexOps,
609 row: { type: string; refId: string },
610 workspaceId: string
611): Promise<IndexDocInput | null> {
612 const d = deps(ops);
613 switch (row.type as SearchType) {
614 case "page":
615 return buildPageDoc(d, row.refId);
616 case "block":
617 return blockDocById(d, row.refId);
618 case "database":
619 return buildDatabaseDoc(d, row.refId);
620 case "record":
621 return buildRecordDoc(d, row.refId);
622 case "comment":
623 return buildCommentDoc(d, row.refId);
624 case "file":
625 return buildFileDoc(d, row.refId);
626 case "user": {
627 const uid = row.refId.split(":").pop() ?? row.refId;
628 return buildUserDoc(d, uid, workspaceId);
629 }
630 default:
631 return null;
632 }
633}
634
635
636async function blockDocById(d: IndexerDeps, blockId: string): Promise<IndexDocInput | null> {
637 const block = await d.prisma.block.findUnique({
638 where: { id: blockId },
639 select: { id: true, pageId: true, deletedAt: true },
640 });
641 if (!block || block.deletedAt !== null || !block.pageId) return null;
642 const docs = await buildBlockDocs(d, block.pageId);
643 return docs.find((x) => x.blockId === blockId) ?? null;
644}
645
646export async function runReconcile(ops: SearchIndexOps, workspaceId?: string): Promise<ReconcileResult> {
647 await ensureContentIndexOnce(ops);
648 const { prisma } = ops;
649 const workspaces = workspaceId
650 ? [workspaceId]
651 : (await prisma.workspace.findMany({ select: { id: true } })).map((w) => w.id);
652 const result: ReconcileResult = { workspaces: 0, checked: 0, repaired: 0, removed: 0 };
653
654 for (const ws of workspaces) {
655 const rows = await prisma.searchDocument.findMany({
656 where: { workspaceId: ws },
657 select: { type: true, refId: true, contentHash: true, indexedAt: true },
658 });
659 for (let i = 0; i < rows.length; i += BATCH) {
660 const batch = rows.slice(i, i + BATCH);
661
662
663
664
665 let present = new Set<string>();
666 try {
667 present = await ops.search.docExists(batch.map((r) => docIdFor(r.type as SearchType, r.refId)));
668 } catch (err) {
669
670
671 log.warn({ err: (err as Error).message, workspaceId: ws }, "docExists failed — index-loss check skipped for batch");
672 present = new Set(batch.map((r) => docIdFor(r.type as SearchType, r.refId)));
673 }
674 for (const row of batch) {
675 result.checked++;
676 const doc = await docFromMirrorRow(ops, row, ws);
677 if (doc === null) {
678 const refId = row.type === "user" ? row.refId.split(":").pop() ?? row.refId : row.refId;
679 await removeDoc(ops, row.type as SearchType, refId, ws);
680 result.removed++;
681 continue;
682 }
683 const docId = docIdFor(row.type as SearchType, row.refId);
684 if (!present.has(docId)) {
685
686 await indexDocs(ops, [doc]);
687 result.repaired++;
688 continue;
689 }
690 if (row.contentHash !== null && row.contentHash === contentHash(doc)) continue;
691 await indexDocs(ops, [doc]);
692 result.repaired++;
693 }
694 if (i + BATCH < rows.length) {
695 log.info({ workspaceId: ws, batch: i / BATCH + 1, totalBatches: Math.ceil(rows.length / BATCH) }, "reconcile batch");
696 }
697 }
698
699
700
701
702
703
704 const live = await collectWorkspaceDocs(ops, ws);
705 const covered = new Set(
706 rows.map((r) => `${r.type}:${r.type === "user" ? (r.refId.split(":").pop() ?? r.refId) : r.refId}`)
707 );
708 const missing: IndexDocInput[] = [];
709 for (const doc of live) {
710 if (covered.has(`${doc.type}:${mirrorRefId(doc)}`)) continue;
711 let present = false;
712 try {
713
714
715 const id = docIdFor(doc.type as SearchType, doc.id);
716 present = (await ops.search.docExists([id])).has(id);
717 } catch {
718 present = true;
719 }
720 if (!present) missing.push(doc);
721 }
722 if (missing.length > 0) {
723 await indexDocs(ops, missing);
724 result.repaired += missing.length;
725 log.info({ workspaceId: ws, readded: missing.length }, "reconcile: source→index re-adds (restored/lost before mirror)");
726 }
727
728 result.workspaces++;
729 }
730 log.info(result, "reconcile complete");
731 return result;
732}
733
734
735
736
737
738
739export async function recordSearchDlq(connection: Redis, job: Job, err: Error): Promise<void> {
740 const data = (job.data ?? {}) as Record<string, unknown>;
741 await connection.xadd(
742 DLQ_STREAM,
743 "MAXLEN",
744 "~",
745 "1000",
746 "*",
747 "jobId",
748 job.id ?? "",
749 "jobType",
750 str(data.type) || "unknown",
751 "workspaceId",
752 str(data.workspaceId),
753 "error",
754 err.message,
755 "ts",
756 new Date().toISOString()
757 );
758 log.error({ jobId: job.id, err: err.message }, "search-index job dead-lettered");
759}
760
761
762
763
764
765export const searchIndexJobSchema = z.discriminatedUnion("type", [
766 z.object({ type: z.literal("reindex"), workspaceId: z.string().min(1).optional(), orgId: z.string().min(1).optional() }),
767 z.object({ type: z.literal("reconcile"), workspaceId: z.string().min(1).optional() }),
768 z.object({
769 type: z.literal("delete"),
770 docType: z.enum(["page", "block", "database", "record", "comment", "file", "user"]),
771 refId: z.string().min(1),
772 workspaceId: z.string().min(1).optional(),
773 }),
774]);
775export type SearchIndexJobData = z.infer<typeof searchIndexJobSchema>;
776
777export function createSearchIndexWorker(opts: {
778 connection: Redis;
779 ops: SearchIndexOps;
780 concurrency?: number;
781}): Worker {
782 const { connection, ops, concurrency } = opts;
783 return new Worker(
784 QUEUES.searchIndex,
785 withJobOtel(QUEUES.searchIndex, async (job: Job) => {
786 const data = searchIndexJobSchema.parse(job.data);
787 const started = Date.now();
788 switch (data.type) {
789 case "reindex": {
790 const counts = await runReindex(ops, data.workspaceId, data.orgId);
791 log.info({ jobId: job.id, counts, ms: Date.now() - started }, "reindex done");
792 return counts;
793 }
794 case "reconcile": {
795 const result = await runReconcile(ops, data.workspaceId);
796 log.info({ jobId: job.id, result, ms: Date.now() - started }, "reconcile done");
797
798
799
800
801 try {
802 await connection.set("search:reconcile:last", new Date().toISOString(), "EX", 30 * 86_400);
803 } catch (err) {
804 log.warn({ jobId: job.id, err: String(err) }, "search:reconcile:last stamp failed (non-fatal)");
805 }
806 return result;
807 }
808 case "delete": {
809 await removeDoc(ops, data.docType, data.refId, data.workspaceId);
810 return { deleted: true };
811 }
812 }
813 }),
814 {
815 connection,
816 ...(concurrency !== undefined ? { concurrency } : {})
817 }
818 );
819}
820
821function str(v: unknown): string {
822 return typeof v === "string" ? v : "";
823}
824