@disco_trooper/apple-notes-mcp 1.7.0 → 1.8.2

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.
package/README.md CHANGED
@@ -18,13 +18,14 @@ MCP server for Apple Notes with semantic search and CRUD operations. Claude sear
18
18
  - **Semantic Search** - Find notes by meaning, not keywords
19
19
  - **Full CRUD** - Create, read, update, delete, and move notes
20
20
  - **Incremental Indexing** - Re-embed only changed notes
21
+ - **Background Index Jobs** - Async full/incremental indexing with progress polling
21
22
  - **Dual Embedding** - Local HuggingFace or OpenRouter API
22
23
 
23
- ## What's New in 1.7
24
+ ## What's New in 1.8.1
24
25
 
25
- - **Hybrid Fallback Indexing** - Recovers from failures by falling back: single call folder batch note-by-note
26
- - **Streaming Batches** - Processes embeddings in batches to reduce peak memory
27
- - **Skipped Notes Reporting** - Shows which notes failed (locked, syncing, corrupted)
26
+ - **Faster `list-notes` folder filtering** - `list-notes` now queries only the requested folder instead of scanning all notes first
27
+ - **Duplicate folder name correctness** - Folder filtering now aggregates matching folders across accounts
28
+ - **Large vault performance** - Folder-scoped listing is significantly faster on larger note libraries
28
29
 
29
30
  ## Installation
30
31
 
@@ -76,10 +77,19 @@ Configuration stored in `~/.apple-notes-mcp/.env`:
76
77
  | `EMBEDDING_MODEL` | Model name (local or OpenRouter) | `Xenova/multilingual-e5-small` |
77
78
  | `EMBEDDING_DIMS` | Embedding dimensions | `4096` |
78
79
  | `READONLY_MODE` | Block all write operations | `false` |
79
- | `INDEX_TTL` | Auto-reindex interval in seconds | - |
80
+ | `INDEX_TTL` | Auto-refresh-on-search interval in seconds (disabled when unset) | - |
81
+ | `SEARCH_REFRESH_TIMEOUT_MS` | Max time search waits for refresh before using stale index | `2000` |
82
+ | `INDEX_JOB_RETENTION_SECONDS` | How long completed/failed index jobs remain queryable | `3600` |
80
83
  | `EMBEDDING_BATCH_SIZE` | Batch size for embedding generation | `50` |
81
84
  | `DEBUG` | Enable debug logging | `false` |
82
85
 
86
+ ### Search Auto-Refresh Policy
87
+
88
+ - `search-notes` does **not** force refresh on every request.
89
+ - If `INDEX_TTL` is unset, auto-refresh is disabled and search uses the current index.
90
+ - If `INDEX_TTL` is set, refresh runs only after TTL expiration.
91
+ - If refresh fails or takes longer than `SEARCH_REFRESH_TIMEOUT_MS`, search falls back to stale index results instead of timing out.
92
+
83
93
  To reconfigure:
84
94
 
85
95
  ```bash
@@ -121,6 +131,8 @@ limit: 10 # max notes to return (1-100)
121
131
  folder: "Work" # filter by folder (case-insensitive)
122
132
  ```
123
133
 
134
+ When `folder` is provided, the server fetches only matching folders from Apple Notes. This keeps folder-scoped requests fast even when your vault has hundreds of notes.
135
+
124
136
  **Examples:**
125
137
  - Get 5 newest notes: `{ sort_by: "created", order: "desc", limit: 5 }`
126
138
  - Recently modified: `{ sort_by: "modified", limit: 10 }`
@@ -164,10 +176,48 @@ Index notes for semantic search.
164
176
  ```
165
177
  mode: "incremental" # incremental (default) or full
166
178
  force: false # force reindex even if TTL hasn't expired
179
+ background: false # optional; defaults to false (synchronous mode)
167
180
  ```
168
181
 
169
182
  Use `mode: "full"` to create the chunk index for better long-note search. First full index takes longer as it generates chunks, but subsequent searches run fast.
170
183
 
184
+ For large vaults, prefer background indexing:
185
+
186
+ #### `start-index-job`
187
+
188
+ ```
189
+ mode: "full" # full or incremental
190
+ ```
191
+
192
+ Returns a job snapshot with `id`, `status`, and `progress`.
193
+ Progress updates in smaller steps across fetch, embed, and persist phases.
194
+
195
+ #### `get-index-job`
196
+
197
+ ```
198
+ job_id: "<job-id>"
199
+ ```
200
+
201
+ Poll until status is `completed`, `failed`, or `cancelled`.
202
+ You may see `cancelling` as a transitional status.
203
+
204
+ #### `list-index-jobs`
205
+
206
+ ```
207
+ limit: 10 # optional, 1-50
208
+ ```
209
+
210
+ #### `cancel-index-job`
211
+
212
+ ```
213
+ job_id: "<job-id>"
214
+ ```
215
+
216
+ Requests best-effort cancellation for a running job. Cancellation is cooperative:
217
+ - A long-running step must reach a cancellation checkpoint.
218
+ - Partial work may remain.
219
+ - Start a new job after the current one reaches `cancelled`.
220
+
171
221
  #### `reindex-note`
172
222
  Re-index a single note after manual edits.
173
223
 
@@ -186,6 +236,9 @@ content: "# Heading\n\nMarkdown content..."
186
236
  folder: "Work" # optional, defaults to Notes
187
237
  ```
188
238
 
239
+ After create, update, delete, or move, the server auto-syncs vector and chunk indexes in best-effort mode.
240
+ If sync partly fails, the tool response includes an `index sync warning`. Run `reindex-note` or `index-notes`.
241
+
189
242
  #### `update-note`
190
243
  Update an existing note.
191
244
 
@@ -333,6 +386,15 @@ Set `READONLY_MODE=false` in `.env` to enable write operations.
333
386
  ### Notes missing from search
334
387
  Run `index-notes` to update the search index. Use `mode: full` if incremental misses changes.
335
388
 
389
+ ### "iCloud account not available" / `Can't get account "iCloud"`
390
+ This error comes from a different Apple Notes MCP implementation that uses tool `search_notes` and argument `Keywords`.
391
+
392
+ This project uses:
393
+ - tool: `search-notes`
394
+ - argument: `query`
395
+
396
+ If your client calls `search_notes` with `Keywords`, point your MCP config to `apple-notes-mcp` and restart the client.
397
+
336
398
  ### JXA errors
337
399
  Ensure Apple Notes runs and contains notes. Grant automation permissions when prompted.
338
400
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@disco_trooper/apple-notes-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.2",
4
4
  "description": "MCP server for Apple Notes with semantic search and CRUD operations",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -19,6 +19,7 @@ export const RRF_K = 60; // Reciprocal Rank Fusion constant
19
19
 
20
20
  // Timeouts and retries
21
21
  export const OPENROUTER_TIMEOUT_MS = 30000;
22
+ export const DEFAULT_SEARCH_REFRESH_TIMEOUT_MS = 2000;
22
23
 
23
24
  // Cache settings
24
25
  export const EMBEDDING_CACHE_MAX_SIZE = 1000;
@@ -52,6 +53,10 @@ export const DEFAULT_CHUNK_OVERLAP = 100;
52
53
  // Batch processing
53
54
  export const DEFAULT_EMBEDDING_BATCH_SIZE = 50;
54
55
 
56
+ // Background indexing jobs
57
+ export const DEFAULT_INDEX_JOB_RETENTION_SECONDS = 3600;
58
+ export const MAX_INDEX_JOB_HISTORY = 100;
59
+
55
60
  /**
56
61
  * Get embedding batch size from environment or use default.
57
62
  * Lower values reduce peak memory usage but increase processing time.
@@ -28,6 +28,8 @@ describe("validateEnv", () => {
28
28
  delete process.env.READONLY_MODE;
29
29
  delete process.env.EMBEDDING_DIMS;
30
30
  delete process.env.INDEX_TTL;
31
+ delete process.env.SEARCH_REFRESH_TIMEOUT_MS;
32
+ delete process.env.INDEX_JOB_RETENTION_SECONDS;
31
33
  const { validateEnv } = await import("./env.js");
32
34
  expect(() => validateEnv()).not.toThrow();
33
35
  });
@@ -55,4 +57,16 @@ describe("validateEnv", () => {
55
57
  const { validateEnv } = await import("./env.js");
56
58
  expect(() => validateEnv()).toThrow();
57
59
  });
60
+
61
+ it("validates SEARCH_REFRESH_TIMEOUT_MS is numeric", async () => {
62
+ process.env.SEARCH_REFRESH_TIMEOUT_MS = "not-a-number";
63
+ const { validateEnv } = await import("./env.js");
64
+ expect(() => validateEnv()).toThrow();
65
+ });
66
+
67
+ it("validates INDEX_JOB_RETENTION_SECONDS is numeric", async () => {
68
+ process.env.INDEX_JOB_RETENTION_SECONDS = "abc";
69
+ const { validateEnv } = await import("./env.js");
70
+ expect(() => validateEnv()).toThrow();
71
+ });
58
72
  });
package/src/config/env.ts CHANGED
@@ -6,6 +6,8 @@ const EnvSchema = z.object({
6
6
  EMBEDDING_DIMS: z.string().regex(/^\d+$/).optional(),
7
7
  READONLY_MODE: z.enum(["true", "false"]).optional(),
8
8
  INDEX_TTL: z.string().regex(/^\d+$/).optional(),
9
+ SEARCH_REFRESH_TIMEOUT_MS: z.string().regex(/^\d+$/).optional(),
10
+ INDEX_JOB_RETENTION_SECONDS: z.string().regex(/^\d+$/).optional(),
9
11
  DEBUG: z.enum(["true", "false"]).optional(),
10
12
  });
11
13
 
@@ -72,6 +72,20 @@ describe("LanceDBStore", () => {
72
72
  await store.index([createTestRecord("Existing")]);
73
73
  await expect(store.delete("Non Existent")).resolves.not.toThrow();
74
74
  });
75
+
76
+ it("deleteByIdAndFolderAndTitle removes only matching record", async () => {
77
+ const base = createTestRecord("Shared");
78
+ const other = { ...createTestRecord("Shared"), id: "other-id" };
79
+
80
+ await store.index([base, other]);
81
+ expect(await store.count()).toBe(2);
82
+
83
+ await store.deleteByIdAndFolderAndTitle(base.id, base.folder, base.title);
84
+
85
+ expect(await store.count()).toBe(1);
86
+ const remaining = await store.getByTitle("Shared");
87
+ expect(remaining?.id).toBe("other-id");
88
+ });
75
89
  });
76
90
 
77
91
  describe("getAll", () => {
package/src/db/lancedb.ts CHANGED
@@ -20,6 +20,13 @@ export interface NoteRecord {
20
20
  [key: string]: unknown; // Index signature for LanceDB compatibility
21
21
  }
22
22
 
23
+ export interface IndexMetadataRecord {
24
+ id: string;
25
+ title: string;
26
+ folder: string;
27
+ indexed_at: string;
28
+ }
29
+
23
30
  // Schema for chunked notes (Parent Document Retriever pattern)
24
31
  export interface ChunkRecord {
25
32
  chunk_id: string; // `${note_id}_chunk_${index}`
@@ -48,9 +55,11 @@ export interface VectorStore {
48
55
  update(record: NoteRecord): Promise<void>;
49
56
  delete(title: string): Promise<void>;
50
57
  deleteByFolderAndTitle(folder: string, title: string): Promise<void>;
58
+ deleteByIdAndFolderAndTitle(id: string, folder: string, title: string): Promise<void>;
51
59
  search(queryVector: number[], limit: number): Promise<SearchResult[]>;
52
60
  searchFTS(query: string, limit: number): Promise<SearchResult[]>;
53
61
  getByTitle(title: string): Promise<NoteRecord | null>;
62
+ getIndexMetadata(): Promise<IndexMetadataRecord[]>;
54
63
  getAll(): Promise<NoteRecord[]>;
55
64
  count(): Promise<number>;
56
65
  clear(): Promise<void>;
@@ -251,6 +260,18 @@ export class LanceDBStore implements VectorStore {
251
260
  debug(`Deleted record: ${folder}/${title}`);
252
261
  }
253
262
 
263
+ async deleteByIdAndFolderAndTitle(id: string, folder: string, title: string): Promise<void> {
264
+ const table = await this.ensureTable();
265
+ const validTitle = validateTitle(title);
266
+ const escapedId = escapeForFilter(id);
267
+ const escapedTitle = escapeForFilter(validTitle);
268
+ const escapedFolder = escapeForFilter(folder);
269
+ await table.delete(
270
+ `id = '${escapedId}' AND folder = '${escapedFolder}' AND title = '${escapedTitle}'`
271
+ );
272
+ debug(`Deleted record by id: ${id} (${folder}/${title})`);
273
+ }
274
+
254
275
  async search(queryVector: number[], limit: number): Promise<SearchResult[]> {
255
276
  const table = await this.ensureTable();
256
277
 
@@ -295,6 +316,22 @@ export class LanceDBStore implements VectorStore {
295
316
  return results[0] as unknown as NoteRecord;
296
317
  }
297
318
 
319
+ async getIndexMetadata(): Promise<IndexMetadataRecord[]> {
320
+ const table = await this.ensureTable();
321
+
322
+ const results = await table
323
+ .query()
324
+ .select(["id", "title", "folder", "indexed_at"])
325
+ .toArray();
326
+
327
+ return results.map((row): IndexMetadataRecord => ({
328
+ id: (row.id as string) ?? "",
329
+ title: row.title as string,
330
+ folder: row.folder as string,
331
+ indexed_at: row.indexed_at as string,
332
+ }));
333
+ }
334
+
298
335
  async getAll(): Promise<NoteRecord[]> {
299
336
  const table = await this.ensureTable();
300
337
 
package/src/index.ts CHANGED
@@ -32,6 +32,13 @@ import { indexNotes, reindexNote } from "./search/indexer.js";
32
32
  import { fullChunkIndex, hasChunkIndex } from "./search/chunk-indexer.js";
33
33
  import { searchChunks } from "./search/chunk-search.js";
34
34
  import { refreshIfNeeded } from "./search/refresh.js";
35
+ import { getIndexJobManager } from "./indexing/job-manager.js";
36
+ import {
37
+ syncAfterCreate,
38
+ syncAfterUpdate,
39
+ syncAfterDelete,
40
+ syncAfterMove,
41
+ } from "./search/write-sync.js";
35
42
  import { listTags, searchByTag, findRelatedNotes } from "./graph/queries.js";
36
43
  import { exportGraph } from "./graph/export.js";
37
44
  import { findTables, parseTable } from "./notes/tables.js";
@@ -85,6 +92,23 @@ const SearchNotesSchema = z.object({
85
92
  const IndexNotesSchema = z.object({
86
93
  mode: z.enum(["full", "incremental"]).default("incremental"),
87
94
  force: z.boolean().default(false),
95
+ background: z.boolean().optional(),
96
+ });
97
+
98
+ const StartIndexJobSchema = z.object({
99
+ mode: z.enum(["full", "incremental"]).default("incremental"),
100
+ });
101
+
102
+ const GetIndexJobSchema = z.object({
103
+ job_id: z.string().min(1),
104
+ });
105
+
106
+ const ListIndexJobsSchema = z.object({
107
+ limit: z.number().min(1).max(50).default(10),
108
+ });
109
+
110
+ const CancelIndexJobSchema = z.object({
111
+ job_id: z.string().min(1),
88
112
  });
89
113
 
90
114
  const ReindexNoteSchema = z.object({
@@ -254,10 +278,71 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
254
278
  type: "boolean",
255
279
  description: "Force reindex even if TTL hasn't expired (default: false)"
256
280
  },
281
+ background: {
282
+ type: "boolean",
283
+ description: "Run indexing as async background job (default: false)"
284
+ },
285
+ },
286
+ required: [],
287
+ },
288
+ },
289
+ {
290
+ name: "start-index-job",
291
+ description: "Start background indexing job and return job id for polling",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: {
295
+ mode: {
296
+ type: "string",
297
+ enum: ["full", "incremental"],
298
+ description: "Index mode (default: incremental)",
299
+ },
300
+ },
301
+ required: [],
302
+ },
303
+ },
304
+ {
305
+ name: "get-index-job",
306
+ description: "Get background indexing job status and progress",
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ job_id: {
311
+ type: "string",
312
+ description: "Job ID returned from start-index-job or index-notes",
313
+ },
314
+ },
315
+ required: ["job_id"],
316
+ },
317
+ },
318
+ {
319
+ name: "list-index-jobs",
320
+ description: "List recent background indexing jobs",
321
+ inputSchema: {
322
+ type: "object",
323
+ properties: {
324
+ limit: {
325
+ type: "number",
326
+ description: "Max jobs to return (default: 10)",
327
+ },
257
328
  },
258
329
  required: [],
259
330
  },
260
331
  },
332
+ {
333
+ name: "cancel-index-job",
334
+ description: "Request cancellation for a running background indexing job",
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ job_id: {
339
+ type: "string",
340
+ description: "Job ID returned from start-index-job or index-notes",
341
+ },
342
+ },
343
+ required: ["job_id"],
344
+ },
345
+ },
261
346
  {
262
347
  name: "reindex-note",
263
348
  description: "Re-index a single note after manual edits",
@@ -586,6 +671,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
586
671
 
587
672
  case "index-notes": {
588
673
  const params = IndexNotesSchema.parse(args);
674
+ const jobs = getIndexJobManager();
675
+
676
+ const runInBackground = params.background ?? false;
677
+ if (runInBackground) {
678
+ const job = jobs.start({ mode: params.mode });
679
+ return textResponse(
680
+ JSON.stringify(
681
+ {
682
+ message: `Started ${params.mode} index job`,
683
+ job_id: job.id,
684
+ status: job.status,
685
+ progress: job.progress,
686
+ },
687
+ null,
688
+ 2
689
+ )
690
+ );
691
+ }
692
+
589
693
  const result = await indexNotes(params.mode);
590
694
 
591
695
  let message = `Indexed ${result.indexed} notes in ${(result.timeMs / 1000).toFixed(1)}s`;
@@ -626,6 +730,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
626
730
  return textResponse(message);
627
731
  }
628
732
 
733
+ case "start-index-job": {
734
+ const params = StartIndexJobSchema.parse(args);
735
+ const jobs = getIndexJobManager();
736
+ const job = jobs.start({ mode: params.mode });
737
+ return textResponse(JSON.stringify(job, null, 2));
738
+ }
739
+
740
+ case "get-index-job": {
741
+ const params = GetIndexJobSchema.parse(args);
742
+ const jobs = getIndexJobManager();
743
+ const job = jobs.get(params.job_id);
744
+ if (!job) {
745
+ return errorResponse(`Index job not found: "${params.job_id}"`);
746
+ }
747
+ return textResponse(JSON.stringify(job, null, 2));
748
+ }
749
+
750
+ case "list-index-jobs": {
751
+ const params = ListIndexJobsSchema.parse(args);
752
+ const jobs = getIndexJobManager();
753
+ const list = jobs.list(params.limit);
754
+ return textResponse(JSON.stringify(list, null, 2));
755
+ }
756
+
757
+ case "cancel-index-job": {
758
+ const params = CancelIndexJobSchema.parse(args);
759
+ const jobs = getIndexJobManager();
760
+ const job = jobs.cancel(params.job_id);
761
+ if (!job) {
762
+ return errorResponse(`Index job not found: "${params.job_id}"`);
763
+ }
764
+ return textResponse(JSON.stringify(job, null, 2));
765
+ }
766
+
629
767
  case "reindex-note": {
630
768
  const params = ReindexNoteSchema.parse(args);
631
769
  await reindexNote(params.title);
@@ -726,9 +864,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
726
864
  // Write tools
727
865
  case "create-note": {
728
866
  const params = CreateNoteSchema.parse(args);
729
- await createNote(params.title, params.content, params.folder);
730
- const location = params.folder ? `${params.folder}/${params.title}` : params.title;
731
- return textResponse(`Created note: "${location}"`);
867
+ const result = await createNote(params.title, params.content, params.folder);
868
+ const location = `${result.folder}/${result.title}`;
869
+
870
+ const syncResult = await syncAfterCreate(result);
871
+ const syncWarning = syncResult.warnings.length > 0
872
+ ? ` (index sync warning: ${syncResult.warnings.join("; ")})`
873
+ : "";
874
+
875
+ return textResponse(`Created note: "${location}"${syncWarning}`);
732
876
  }
733
877
 
734
878
  case "update-note": {
@@ -742,15 +886,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
742
886
  : "";
743
887
 
744
888
  if (params.reindex) {
745
- try {
746
- // Use new title for reindexing (Apple Notes may have renamed it)
747
- const reindexTitle = `${result.folder}/${result.newTitle}`;
748
- await reindexNote(reindexTitle);
749
- return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}`);
750
- } catch (reindexError) {
751
- debug("Reindex after update failed:", reindexError);
752
- return textResponse(`Updated note: "${location}"${renamedMsg} (reindexing failed, run index-notes to update)`);
753
- }
889
+ const syncResult = await syncAfterUpdate(result);
890
+ const syncWarning = syncResult.warnings.length > 0
891
+ ? ` (index sync warning: ${syncResult.warnings.join("; ")})`
892
+ : "";
893
+ return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}${syncWarning}`);
754
894
  }
755
895
 
756
896
  return textResponse(`Updated note: "${location}"${renamedMsg}`);
@@ -761,14 +901,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
761
901
  if (!params.confirm) {
762
902
  return errorResponse("Add confirm: true to delete the note");
763
903
  }
764
- await deleteNote(params.title);
765
- return textResponse(`Deleted note: "${params.title}"`);
904
+ const result = await deleteNote(params.title);
905
+ const syncResult = await syncAfterDelete(result);
906
+ const syncWarning = syncResult.warnings.length > 0
907
+ ? ` (index sync warning: ${syncResult.warnings.join("; ")})`
908
+ : "";
909
+ return textResponse(`Deleted note: "${params.title}"${syncWarning}`);
766
910
  }
767
911
 
768
912
  case "move-note": {
769
913
  const params = MoveNoteSchema.parse(args);
770
- await moveNote(params.title, params.folder);
771
- return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
914
+ const result = await moveNote(params.title, params.folder);
915
+ const syncResult = await syncAfterMove(result);
916
+ const syncWarning = syncResult.warnings.length > 0
917
+ ? ` (index sync warning: ${syncResult.warnings.join("; ")})`
918
+ : "";
919
+ return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"${syncWarning}`);
772
920
  }
773
921
 
774
922
  case "edit-table": {
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { IndexCancelledError, isIndexCancelledError } from "./contracts.js";
3
+
4
+ describe("indexing contracts", () => {
5
+ it("creates cancellable error type", () => {
6
+ const err = new IndexCancelledError("user requested cancel");
7
+ expect(isIndexCancelledError(err)).toBe(true);
8
+ });
9
+
10
+ it("does not treat generic Error as cancelled", () => {
11
+ expect(isIndexCancelledError(new Error("x"))).toBe(false);
12
+ });
13
+ });
@@ -0,0 +1,28 @@
1
+ export interface IndexProgressEvent {
2
+ stage: "fetch" | "prepare" | "embed" | "persist" | "delete" | "rebuild-fts" | "done";
3
+ current: number;
4
+ total: number;
5
+ message: string;
6
+ }
7
+
8
+ export interface IndexRunOptions {
9
+ signal?: AbortSignal;
10
+ onProgress?: (event: IndexProgressEvent) => void;
11
+ }
12
+
13
+ export class IndexCancelledError extends Error {
14
+ constructor(message = "Indexing cancelled") {
15
+ super(message);
16
+ this.name = "IndexCancelledError";
17
+ }
18
+ }
19
+
20
+ export function isIndexCancelledError(error: unknown): error is IndexCancelledError {
21
+ return error instanceof IndexCancelledError;
22
+ }
23
+
24
+ export function throwIfCancelled(signal?: AbortSignal): void {
25
+ if (signal?.aborted) {
26
+ throw new IndexCancelledError();
27
+ }
28
+ }