@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 +67 -5
- package/package.json +1 -1
- package/src/config/constants.ts +5 -0
- package/src/config/env.test.ts +14 -0
- package/src/config/env.ts +2 -0
- package/src/db/lancedb.test.ts +14 -0
- package/src/db/lancedb.ts +37 -0
- package/src/index.ts +164 -16
- package/src/indexing/contracts.test.ts +13 -0
- package/src/indexing/contracts.ts +28 -0
- package/src/indexing/job-manager.test.ts +185 -0
- package/src/indexing/job-manager.ts +377 -0
- package/src/notes/crud.test.ts +33 -6
- package/src/notes/crud.ts +62 -7
- package/src/notes/read.test.ts +139 -5
- package/src/notes/read.ts +58 -5
- package/src/search/chunk-indexer.ts +69 -4
- package/src/search/indexer.progress.test.ts +75 -0
- package/src/search/indexer.ts +149 -38
- package/src/search/refresh-policy.test.ts +25 -0
- package/src/search/refresh-policy.ts +33 -0
- package/src/search/refresh.test.ts +146 -25
- package/src/search/refresh.ts +207 -47
- package/src/search/write-sync.test.ts +133 -0
- package/src/search/write-sync.ts +155 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockVectorStore = {
|
|
4
|
+
deleteByFolderAndTitle: vi.fn(),
|
|
5
|
+
deleteByIdAndFolderAndTitle: vi.fn(),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const mockChunkStore = {
|
|
9
|
+
deleteChunksByNoteIds: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
vi.mock("../db/lancedb.js", () => ({
|
|
13
|
+
getVectorStore: vi.fn(() => mockVectorStore),
|
|
14
|
+
getChunkStore: vi.fn(() => mockChunkStore),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./indexer.js", () => ({
|
|
18
|
+
reindexNote: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./chunk-indexer.js", () => ({
|
|
22
|
+
hasChunkIndex: vi.fn().mockResolvedValue(true),
|
|
23
|
+
updateChunksForNotes: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../notes/read.js", () => ({
|
|
27
|
+
getNoteById: vi.fn().mockResolvedValue({
|
|
28
|
+
id: "note-1",
|
|
29
|
+
title: "A",
|
|
30
|
+
folder: "Work",
|
|
31
|
+
content: "Content",
|
|
32
|
+
htmlContent: "<p>Content</p>",
|
|
33
|
+
created: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
modified: "2026-01-01T00:00:00.000Z",
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("../utils/debug.js", () => ({
|
|
39
|
+
createDebugLogger: vi.fn(() => vi.fn()),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
describe("write-sync", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("syncAfterCreate reindexes vector and chunks by note id", async () => {
|
|
48
|
+
const { syncAfterCreate } = await import("./write-sync.js");
|
|
49
|
+
const { reindexNote } = await import("./indexer.js");
|
|
50
|
+
const { updateChunksForNotes } = await import("./chunk-indexer.js");
|
|
51
|
+
|
|
52
|
+
const result = await syncAfterCreate({
|
|
53
|
+
id: "note-1",
|
|
54
|
+
title: "A",
|
|
55
|
+
folder: "Work",
|
|
56
|
+
requestedTitle: "A",
|
|
57
|
+
titleChanged: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(reindexNote).toHaveBeenCalledWith("id:note-1");
|
|
61
|
+
expect(updateChunksForNotes).toHaveBeenCalled();
|
|
62
|
+
expect(result.ok).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("syncAfterDelete removes vector and chunk records", async () => {
|
|
66
|
+
const { syncAfterDelete } = await import("./write-sync.js");
|
|
67
|
+
|
|
68
|
+
const result = await syncAfterDelete({
|
|
69
|
+
id: "note-1",
|
|
70
|
+
title: "A",
|
|
71
|
+
folder: "Work",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(mockVectorStore.deleteByIdAndFolderAndTitle).toHaveBeenCalledWith("note-1", "Work", "A");
|
|
75
|
+
expect(mockChunkStore.deleteChunksByNoteIds).toHaveBeenCalledWith(["note-1"]);
|
|
76
|
+
expect(result.ok).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("syncAfterMove deletes old folder/title and reindexes by id", async () => {
|
|
80
|
+
const { syncAfterMove } = await import("./write-sync.js");
|
|
81
|
+
const { reindexNote } = await import("./indexer.js");
|
|
82
|
+
|
|
83
|
+
const result = await syncAfterMove({
|
|
84
|
+
id: "note-1",
|
|
85
|
+
title: "A",
|
|
86
|
+
fromFolder: "Work",
|
|
87
|
+
toFolder: "Archive",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(mockVectorStore.deleteByIdAndFolderAndTitle).toHaveBeenCalledWith("note-1", "Work", "A");
|
|
91
|
+
expect(reindexNote).toHaveBeenCalledWith("id:note-1");
|
|
92
|
+
expect(result.ok).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("syncAfterUpdate deletes stale title entry when renamed", async () => {
|
|
96
|
+
const { syncAfterUpdate } = await import("./write-sync.js");
|
|
97
|
+
const { reindexNote } = await import("./indexer.js");
|
|
98
|
+
|
|
99
|
+
const result = await syncAfterUpdate({
|
|
100
|
+
id: "note-1",
|
|
101
|
+
originalTitle: "Old A",
|
|
102
|
+
newTitle: "New A",
|
|
103
|
+
folder: "Work",
|
|
104
|
+
titleChanged: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mockVectorStore.deleteByIdAndFolderAndTitle).toHaveBeenCalledWith("note-1", "Work", "Old A");
|
|
108
|
+
expect(reindexNote).toHaveBeenCalledWith("id:note-1");
|
|
109
|
+
expect(result.ok).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reindexes before stale cleanup on move", async () => {
|
|
113
|
+
const { syncAfterMove } = await import("./write-sync.js");
|
|
114
|
+
const { reindexNote } = await import("./indexer.js");
|
|
115
|
+
|
|
116
|
+
const order: string[] = [];
|
|
117
|
+
vi.mocked(reindexNote).mockImplementation(async () => {
|
|
118
|
+
order.push("reindex");
|
|
119
|
+
});
|
|
120
|
+
mockVectorStore.deleteByIdAndFolderAndTitle.mockImplementation(async () => {
|
|
121
|
+
order.push("cleanup");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await syncAfterMove({
|
|
125
|
+
id: "note-1",
|
|
126
|
+
title: "A",
|
|
127
|
+
fromFolder: "Work",
|
|
128
|
+
toFolder: "Archive",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(order).toEqual(["reindex", "cleanup"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getVectorStore, getChunkStore } from "../db/lancedb.js";
|
|
2
|
+
import { getNoteById } from "../notes/read.js";
|
|
3
|
+
import {
|
|
4
|
+
hasChunkIndex,
|
|
5
|
+
updateChunksForNotes,
|
|
6
|
+
} from "./chunk-indexer.js";
|
|
7
|
+
import { reindexNote } from "./indexer.js";
|
|
8
|
+
import type {
|
|
9
|
+
CreateResult,
|
|
10
|
+
DeleteResult,
|
|
11
|
+
MoveResult,
|
|
12
|
+
UpdateResult,
|
|
13
|
+
} from "../notes/crud.js";
|
|
14
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
15
|
+
|
|
16
|
+
const debug = createDebugLogger("WRITE-SYNC");
|
|
17
|
+
|
|
18
|
+
export interface WriteSyncResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
warnings: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function trySync(action: string, operation: () => Promise<void>): Promise<string | null> {
|
|
24
|
+
try {
|
|
25
|
+
await operation();
|
|
26
|
+
return null;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
debug(`Write sync step failed (${action}):`, error);
|
|
30
|
+
return `${action}: ${message}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function syncChunksForNoteId(noteId: string): Promise<string | null> {
|
|
35
|
+
try {
|
|
36
|
+
const hasChunks = await hasChunkIndex();
|
|
37
|
+
if (!hasChunks) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const note = await getNoteById(noteId);
|
|
42
|
+
if (!note) {
|
|
43
|
+
return `chunk-sync: note not found for id ${noteId}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await updateChunksForNotes([note]);
|
|
47
|
+
return null;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
return `chunk-sync: ${message}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toResult(warnings: Array<string | null>): WriteSyncResult {
|
|
55
|
+
const filtered = warnings.filter((w): w is string => w !== null);
|
|
56
|
+
return {
|
|
57
|
+
ok: filtered.length === 0,
|
|
58
|
+
warnings: filtered,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function syncAfterCreate(createResult: CreateResult): Promise<WriteSyncResult> {
|
|
63
|
+
const warnings: Array<string | null> = [];
|
|
64
|
+
|
|
65
|
+
warnings.push(
|
|
66
|
+
await trySync("vector-reindex", async () => {
|
|
67
|
+
await reindexNote(`id:${createResult.id}`);
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
warnings.push(await syncChunksForNoteId(createResult.id));
|
|
72
|
+
|
|
73
|
+
return toResult(warnings);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function syncAfterUpdate(updateResult: UpdateResult): Promise<WriteSyncResult> {
|
|
77
|
+
const warnings: Array<string | null> = [];
|
|
78
|
+
|
|
79
|
+
const reindexWarning = await trySync("vector-reindex", async () => {
|
|
80
|
+
await reindexNote(`id:${updateResult.id}`);
|
|
81
|
+
});
|
|
82
|
+
warnings.push(reindexWarning);
|
|
83
|
+
|
|
84
|
+
// If title changed, remove stale vector entries only after successful reindex.
|
|
85
|
+
if (updateResult.titleChanged && reindexWarning === null) {
|
|
86
|
+
warnings.push(
|
|
87
|
+
await trySync("vector-delete-old-title", async () => {
|
|
88
|
+
const store = getVectorStore();
|
|
89
|
+
await store.deleteByIdAndFolderAndTitle(
|
|
90
|
+
updateResult.id,
|
|
91
|
+
updateResult.folder,
|
|
92
|
+
updateResult.originalTitle
|
|
93
|
+
);
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
warnings.push(await syncChunksForNoteId(updateResult.id));
|
|
99
|
+
|
|
100
|
+
return toResult(warnings);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function syncAfterDelete(deleteResult: DeleteResult): Promise<WriteSyncResult> {
|
|
104
|
+
const warnings: Array<string | null> = [];
|
|
105
|
+
|
|
106
|
+
warnings.push(
|
|
107
|
+
await trySync("vector-delete", async () => {
|
|
108
|
+
const store = getVectorStore();
|
|
109
|
+
await store.deleteByIdAndFolderAndTitle(
|
|
110
|
+
deleteResult.id,
|
|
111
|
+
deleteResult.folder,
|
|
112
|
+
deleteResult.title
|
|
113
|
+
);
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
warnings.push(
|
|
118
|
+
await trySync("chunk-delete", async () => {
|
|
119
|
+
const hasChunks = await hasChunkIndex();
|
|
120
|
+
if (!hasChunks) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const chunkStore = getChunkStore();
|
|
124
|
+
await chunkStore.deleteChunksByNoteIds([deleteResult.id]);
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return toResult(warnings);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function syncAfterMove(moveResult: MoveResult): Promise<WriteSyncResult> {
|
|
132
|
+
const warnings: Array<string | null> = [];
|
|
133
|
+
|
|
134
|
+
const reindexWarning = await trySync("vector-reindex", async () => {
|
|
135
|
+
await reindexNote(`id:${moveResult.id}`);
|
|
136
|
+
});
|
|
137
|
+
warnings.push(reindexWarning);
|
|
138
|
+
|
|
139
|
+
if (reindexWarning === null) {
|
|
140
|
+
warnings.push(
|
|
141
|
+
await trySync("vector-delete-old-folder", async () => {
|
|
142
|
+
const store = getVectorStore();
|
|
143
|
+
await store.deleteByIdAndFolderAndTitle(
|
|
144
|
+
moveResult.id,
|
|
145
|
+
moveResult.fromFolder,
|
|
146
|
+
moveResult.title
|
|
147
|
+
);
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
warnings.push(await syncChunksForNoteId(moveResult.id));
|
|
153
|
+
|
|
154
|
+
return toResult(warnings);
|
|
155
|
+
}
|