apple-tools-mcp 1.2.0 → 2.0.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.
- package/README.md +261 -7
- package/contacts.js +71 -19
- package/index.js +133 -87
- package/indexer.js +33 -23
- package/lib/appleScript.js +292 -0
- package/lib/audit.js +22 -14
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/indexGate.js +49 -0
- package/lib/lancedbTables.js +307 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +7 -18
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contacts write operations: add, edit, remove.
|
|
3
|
+
*
|
|
4
|
+
* Contacts are addressed by their AddressBook unique id (Contacts.app's
|
|
5
|
+
* `id`, for example "ABCD1234-...:ABPerson"), which `contacts_search` and
|
|
6
|
+
* `contacts_lookup` report as "Contact ID" and `contacts_add` returns.
|
|
7
|
+
*
|
|
8
|
+
* Writes go through Contacts.app rather than the AddressBook sqlite file:
|
|
9
|
+
* writing that database directly corrupts iCloud sync.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { runAppleScript, asString, CONTACTS_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
13
|
+
import {
|
|
14
|
+
planWrite,
|
|
15
|
+
normalizeList,
|
|
16
|
+
isEmailAddress,
|
|
17
|
+
isPhoneNumber,
|
|
18
|
+
validateContactId,
|
|
19
|
+
validateSubject,
|
|
20
|
+
validateLabel,
|
|
21
|
+
writeErrorMessage,
|
|
22
|
+
writeSuccessMessage,
|
|
23
|
+
isFlagTrue,
|
|
24
|
+
truncate
|
|
25
|
+
} from "./writeGuards.js";
|
|
26
|
+
|
|
27
|
+
const MAX_VALUES_PER_FIELD = 10;
|
|
28
|
+
|
|
29
|
+
function validateContactEmails(value, label) {
|
|
30
|
+
const emails = normalizeList(value);
|
|
31
|
+
if (emails.length > MAX_VALUES_PER_FIELD) {
|
|
32
|
+
return { emails: [], error: `at most ${MAX_VALUES_PER_FIELD} email addresses per contact` };
|
|
33
|
+
}
|
|
34
|
+
const bad = emails.filter((e) => !isEmailAddress(e));
|
|
35
|
+
if (bad.length > 0) {
|
|
36
|
+
return { emails: [], error: `invalid email address(es): ${bad.map((e) => truncate(e, 40)).join(", ")}` };
|
|
37
|
+
}
|
|
38
|
+
const resolvedLabel = validateLabel(label, "work");
|
|
39
|
+
if (!resolvedLabel) return { emails: [], error: "email_label must be letters and spaces only" };
|
|
40
|
+
return { emails: emails.map((email) => ({ value: email, label: resolvedLabel })), error: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateContactPhones(value, label) {
|
|
44
|
+
const phones = normalizeList(value);
|
|
45
|
+
if (phones.length > MAX_VALUES_PER_FIELD) {
|
|
46
|
+
return { phones: [], error: `at most ${MAX_VALUES_PER_FIELD} phone numbers per contact` };
|
|
47
|
+
}
|
|
48
|
+
const bad = phones.filter((p) => !isPhoneNumber(p));
|
|
49
|
+
if (bad.length > 0) {
|
|
50
|
+
return { phones: [], error: `invalid phone number(s): ${bad.map((p) => truncate(p, 40)).join(", ")}` };
|
|
51
|
+
}
|
|
52
|
+
const resolvedLabel = validateLabel(label, "mobile");
|
|
53
|
+
if (!resolvedLabel) return { phones: [], error: "phone_label must be letters and spaces only" };
|
|
54
|
+
return { phones: phones.map((phone) => ({ value: phone, label: resolvedLabel })), error: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findPersonHandler() {
|
|
58
|
+
return `on atmFindPerson(theId)
|
|
59
|
+
tell application "Contacts"
|
|
60
|
+
try
|
|
61
|
+
return person id theId
|
|
62
|
+
end try
|
|
63
|
+
try
|
|
64
|
+
set hits to (every person whose id is theId)
|
|
65
|
+
if (count of hits) > 0 then return item 1 of hits
|
|
66
|
+
end try
|
|
67
|
+
end tell
|
|
68
|
+
error "CONTACT_NOT_FOUND"
|
|
69
|
+
end atmFindPerson`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function childLines(items, kind, personVar) {
|
|
73
|
+
return items
|
|
74
|
+
.map((item) => ` make new ${kind} at end of ${kind}s of ${personVar} with properties {label:${asString(item.label)}, value:${asString(item.value)}}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function failure(action, summary, result, secrets = []) {
|
|
79
|
+
if (result.kind === "tcc") {
|
|
80
|
+
return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
|
|
81
|
+
}
|
|
82
|
+
const raw = String(result.error || "");
|
|
83
|
+
if (raw.includes("CONTACT_NOT_FOUND")) {
|
|
84
|
+
return `${action} failed — attempted to ${summary}. No contact with that id was found; use the Contact ID from contacts_search or contacts_lookup.`;
|
|
85
|
+
}
|
|
86
|
+
if (result.kind === "attribution") {
|
|
87
|
+
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
88
|
+
}
|
|
89
|
+
if (result.kind === "app_unavailable") {
|
|
90
|
+
return `${action} failed — attempted to ${summary}. Contacts.app could not be reached on this host.`;
|
|
91
|
+
}
|
|
92
|
+
return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildAddContactScript({ properties, emails, phones }) {
|
|
96
|
+
const props = Object.entries(properties)
|
|
97
|
+
.map(([key, value]) => `${key}:${asString(value)}`)
|
|
98
|
+
.join(", ");
|
|
99
|
+
|
|
100
|
+
return `tell application "Contacts"
|
|
101
|
+
set newPerson to make new person with properties {${props}}
|
|
102
|
+
${emails.length ? `${childLines(emails, "email", "newPerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "newPerson")}\n` : ""} save
|
|
103
|
+
set newId to id of newPerson
|
|
104
|
+
end tell
|
|
105
|
+
return newId`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function contactsAdd(args = {}) {
|
|
109
|
+
const action = "contacts_add";
|
|
110
|
+
|
|
111
|
+
const firstName = validateSubject(args.first_name);
|
|
112
|
+
if (firstName.error) return { ok: false, message: `${action} refused: ${firstName.error.replace("subject", "first_name")}` };
|
|
113
|
+
const lastName = validateSubject(args.last_name);
|
|
114
|
+
if (lastName.error) return { ok: false, message: `${action} refused: ${lastName.error.replace("subject", "last_name")}` };
|
|
115
|
+
const organization = validateSubject(args.organization);
|
|
116
|
+
if (organization.error) return { ok: false, message: `${action} refused: ${organization.error.replace("subject", "organization")}` };
|
|
117
|
+
const jobTitle = validateSubject(args.job_title);
|
|
118
|
+
if (jobTitle.error) return { ok: false, message: `${action} refused: ${jobTitle.error.replace("subject", "job_title")}` };
|
|
119
|
+
|
|
120
|
+
if (!firstName.text && !lastName.text && !organization.text) {
|
|
121
|
+
return { ok: false, message: `${action} refused: provide at least first_name, last_name, or organization.` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const emails = validateContactEmails(args.emails, args.email_label);
|
|
125
|
+
if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
|
|
126
|
+
const phones = validateContactPhones(args.phones, args.phone_label);
|
|
127
|
+
if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
|
|
128
|
+
|
|
129
|
+
const displayName = [firstName.text, lastName.text].filter(Boolean).join(" ") || organization.text;
|
|
130
|
+
const summary = `create contact "${truncate(displayName, 100)}"` +
|
|
131
|
+
`${emails.emails.length ? ` with ${emails.emails.length} email(s)` : ""}` +
|
|
132
|
+
`${phones.phones.length ? ` and ${phones.phones.length} phone number(s)` : ""}`;
|
|
133
|
+
|
|
134
|
+
const plan = planWrite({ action, summary, dryRun: isFlagTrue(args.dry_run), confirm: isFlagTrue(args.confirm) });
|
|
135
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
136
|
+
|
|
137
|
+
const properties = {};
|
|
138
|
+
if (firstName.text) properties["first name"] = firstName.text;
|
|
139
|
+
if (lastName.text) properties["last name"] = lastName.text;
|
|
140
|
+
if (organization.text) properties.organization = organization.text;
|
|
141
|
+
if (jobTitle.text) properties["job title"] = jobTitle.text;
|
|
142
|
+
|
|
143
|
+
const result = runAppleScript(
|
|
144
|
+
buildAddContactScript({ properties, emails: emails.emails, phones: phones.phones }),
|
|
145
|
+
{ timeout: 60000, appName: "Contacts" }
|
|
146
|
+
);
|
|
147
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
message: writeSuccessMessage(action, "contact created", {
|
|
152
|
+
contact_id: result.output,
|
|
153
|
+
name: displayName,
|
|
154
|
+
emails: emails.emails.map((e) => e.value).join(", ") || undefined,
|
|
155
|
+
phones: phones.phones.map((p) => p.value).join(", ") || undefined
|
|
156
|
+
})
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildEditContactScript({ contactId, properties, emails, phones, replaceEmails, replacePhones }) {
|
|
161
|
+
const lines = Object.entries(properties).map(([key, value]) => ` set ${key} of thePerson to ${asString(value)}`);
|
|
162
|
+
if (replaceEmails) {
|
|
163
|
+
lines.push(` try
|
|
164
|
+
delete every email of thePerson
|
|
165
|
+
end try`);
|
|
166
|
+
}
|
|
167
|
+
if (replacePhones) {
|
|
168
|
+
lines.push(` try
|
|
169
|
+
delete every phone of thePerson
|
|
170
|
+
end try`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${findPersonHandler()}
|
|
174
|
+
|
|
175
|
+
set thePerson to atmFindPerson(${asString(contactId)})
|
|
176
|
+
tell application "Contacts"
|
|
177
|
+
${lines.join("\n")}
|
|
178
|
+
${emails.length ? `${childLines(emails, "email", "thePerson")}\n` : ""}${phones.length ? `${childLines(phones, "phone", "thePerson")}\n` : ""} save
|
|
179
|
+
set editedName to name of thePerson
|
|
180
|
+
end tell
|
|
181
|
+
return editedName`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function contactsEdit(args = {}) {
|
|
185
|
+
const action = "contacts_edit";
|
|
186
|
+
|
|
187
|
+
const contactId = validateContactId(args.contact_id);
|
|
188
|
+
if (!contactId) {
|
|
189
|
+
return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). This tool will not guess which contact you meant.` };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const properties = {};
|
|
193
|
+
const changed = [];
|
|
194
|
+
|
|
195
|
+
const fieldMap = {
|
|
196
|
+
first_name: "first name",
|
|
197
|
+
last_name: "last name",
|
|
198
|
+
organization: "organization",
|
|
199
|
+
job_title: "job title"
|
|
200
|
+
};
|
|
201
|
+
for (const [arg, applescriptProp] of Object.entries(fieldMap)) {
|
|
202
|
+
if (args[arg] === undefined) continue;
|
|
203
|
+
const validated = validateSubject(args[arg]);
|
|
204
|
+
if (validated.error) return { ok: false, message: `${action} refused: ${validated.error.replace("subject", arg)}` };
|
|
205
|
+
properties[applescriptProp] = validated.text;
|
|
206
|
+
changed.push(arg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const emails = validateContactEmails(args.emails, args.email_label);
|
|
210
|
+
if (emails.error) return { ok: false, message: `${action} refused: ${emails.error}` };
|
|
211
|
+
const phones = validateContactPhones(args.phones, args.phone_label);
|
|
212
|
+
if (phones.error) return { ok: false, message: `${action} refused: ${phones.error}` };
|
|
213
|
+
|
|
214
|
+
const replaceEmails = isFlagTrue(args.replace_emails);
|
|
215
|
+
const replacePhones = isFlagTrue(args.replace_phones);
|
|
216
|
+
if (emails.emails.length) changed.push(`${replaceEmails ? "replace" : "add"} ${emails.emails.length} email(s)`);
|
|
217
|
+
if (phones.phones.length) changed.push(`${replacePhones ? "replace" : "add"} ${phones.phones.length} phone(s)`);
|
|
218
|
+
if (replaceEmails && emails.emails.length === 0) changed.push("remove all emails");
|
|
219
|
+
if (replacePhones && phones.phones.length === 0) changed.push("remove all phones");
|
|
220
|
+
|
|
221
|
+
if (changed.length === 0) {
|
|
222
|
+
return { ok: false, message: `${action} refused: nothing to change. Pass at least one of first_name, last_name, organization, job_title, emails, phones.` };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const summary = `update contact ${contactId}: ${changed.join(", ")}`;
|
|
226
|
+
// Dropping every email or phone is destructive, so it needs confirmation.
|
|
227
|
+
const destructive = (replaceEmails && emails.emails.length === 0) || (replacePhones && phones.phones.length === 0);
|
|
228
|
+
const plan = planWrite({
|
|
229
|
+
action,
|
|
230
|
+
summary,
|
|
231
|
+
destructive,
|
|
232
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
233
|
+
confirm: isFlagTrue(args.confirm)
|
|
234
|
+
});
|
|
235
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
236
|
+
|
|
237
|
+
const result = runAppleScript(
|
|
238
|
+
buildEditContactScript({
|
|
239
|
+
contactId,
|
|
240
|
+
properties,
|
|
241
|
+
emails: emails.emails,
|
|
242
|
+
phones: phones.phones,
|
|
243
|
+
replaceEmails,
|
|
244
|
+
replacePhones
|
|
245
|
+
}),
|
|
246
|
+
{ timeout: 60000, appName: "Contacts" }
|
|
247
|
+
);
|
|
248
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
message: writeSuccessMessage(action, "contact updated", {
|
|
253
|
+
contact_id: contactId,
|
|
254
|
+
name: truncate(result.output, 120) || undefined,
|
|
255
|
+
changed: changed.join(", ")
|
|
256
|
+
})
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function buildRemoveContactScript(contactId) {
|
|
261
|
+
return `${findPersonHandler()}
|
|
262
|
+
|
|
263
|
+
set thePerson to atmFindPerson(${asString(contactId)})
|
|
264
|
+
tell application "Contacts"
|
|
265
|
+
set removedName to name of thePerson
|
|
266
|
+
delete thePerson
|
|
267
|
+
save
|
|
268
|
+
end tell
|
|
269
|
+
return removedName`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function contactsRemove(args = {}) {
|
|
273
|
+
const action = "contacts_remove";
|
|
274
|
+
|
|
275
|
+
const contactId = validateContactId(args.contact_id);
|
|
276
|
+
if (!contactId) {
|
|
277
|
+
return { ok: false, message: `${action} refused: contact_id is required (the "Contact ID" from contacts_search or contacts_lookup). Deletes never run on a guessed id.` };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const summary = `delete contact ${contactId}`;
|
|
281
|
+
const plan = planWrite({
|
|
282
|
+
action,
|
|
283
|
+
summary,
|
|
284
|
+
destructive: true,
|
|
285
|
+
dryRun: isFlagTrue(args.dry_run),
|
|
286
|
+
confirm: isFlagTrue(args.confirm)
|
|
287
|
+
});
|
|
288
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
289
|
+
|
|
290
|
+
const result = runAppleScript(buildRemoveContactScript(contactId), { timeout: 60000, appName: "Contacts" });
|
|
291
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
message: writeSuccessMessage(action, "contact deleted", {
|
|
296
|
+
contact_id: contactId,
|
|
297
|
+
name: truncate(result.output, 120) || undefined
|
|
298
|
+
})
|
|
299
|
+
};
|
|
300
|
+
}
|
package/lib/indexGate.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* index cycle." A second MCP instance that lost the indexer lock never runs
|
|
6
6
|
* a cycle, so that flag stays false. Lost-lock must not be treated as
|
|
7
7
|
* "still indexing" — callers still check isIndexReady() for a missing index.
|
|
8
|
+
* isIndexReady() / initDB() must re-list LanceDB tables on each check so a
|
|
9
|
+
* first empty catalog (daemon writer in flight) is not cached forever.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -57,3 +59,50 @@ export function indexUnavailableMessage(type) {
|
|
|
57
59
|
}
|
|
58
60
|
return "Index not available. Please try again shortly.";
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
export const BUILDING_INITIAL_INDEX_MESSAGE =
|
|
64
|
+
"Building initial index. This may take several minutes on first run. Please try again shortly.";
|
|
65
|
+
|
|
66
|
+
export const INDEXING_NEW_DATA_MESSAGE =
|
|
67
|
+
"Indexing new data. Please try again in a moment.";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Still-indexing copy. Only for a process that owns the lock and has not
|
|
71
|
+
* finished a local cycle — never for a lost-lock MCP reader.
|
|
72
|
+
*
|
|
73
|
+
* @param {boolean} isFirstEverRun
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function indexingInProgressMessage(isFirstEverRun) {
|
|
77
|
+
return isFirstEverRun ? BUILDING_INITIAL_INDEX_MESSAGE : INDEXING_NEW_DATA_MESSAGE;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Preflight for index-backed MCP query tools.
|
|
82
|
+
* Readiness is `indexReady` (usable on-disk tables), not sessionIndexComplete.
|
|
83
|
+
* Lost-lock never returns "building initial index" / still-indexing.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* sessionIndexComplete: boolean,
|
|
87
|
+
* ownsIndexLock: boolean,
|
|
88
|
+
* indexReady: boolean,
|
|
89
|
+
* type?: "emails"|"messages"|"calendar",
|
|
90
|
+
* isFirstEverRun?: boolean
|
|
91
|
+
* }} args
|
|
92
|
+
* @returns {{ ok: boolean, message: string|null }}
|
|
93
|
+
*/
|
|
94
|
+
export function indexQueryGate({
|
|
95
|
+
sessionIndexComplete,
|
|
96
|
+
ownsIndexLock,
|
|
97
|
+
indexReady,
|
|
98
|
+
type,
|
|
99
|
+
isFirstEverRun = false
|
|
100
|
+
}) {
|
|
101
|
+
if (isSearchBlockedByIndexing(sessionIndexComplete, ownsIndexLock)) {
|
|
102
|
+
return { ok: false, message: indexingInProgressMessage(isFirstEverRun) };
|
|
103
|
+
}
|
|
104
|
+
if (!indexReady) {
|
|
105
|
+
return { ok: false, message: indexUnavailableMessage(type) };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, message: null };
|
|
108
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared LanceDB table cache for the vector index.
|
|
3
|
+
*
|
|
4
|
+
* MCP stdio and the indexer daemon are separate processes. The daemon keeps a
|
|
5
|
+
* writer connection for its lifetime. A short-lived MCP reader must still see
|
|
6
|
+
* on-disk emails/messages/calendar tables — including when the first connect
|
|
7
|
+
* listed an empty catalog (writer commit in flight, stale snapshot, or a
|
|
8
|
+
* concurrent initDB that returned before openTable finished).
|
|
9
|
+
*
|
|
10
|
+
* Default LanceDB consistency does not re-check other processes. Readers pass
|
|
11
|
+
* readConsistencyInterval: 0 so tableNames()/openTable() see committed catalog.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
|
|
17
|
+
export const INDEX_TABLE_NAMES = ["emails", "messages", "calendar"];
|
|
18
|
+
|
|
19
|
+
/** Strong cross-process read freshness (seconds). 0 = check on every read. */
|
|
20
|
+
export const LANCE_READ_CONSISTENCY_INTERVAL = 0;
|
|
21
|
+
|
|
22
|
+
export const LANCE_CONNECT_OPTIONS = {
|
|
23
|
+
readConsistencyInterval: LANCE_READ_CONSISTENCY_INTERVAL
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* LanceDB stores each table as `<name>.lance` under the index directory.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} indexDir
|
|
30
|
+
* @param {string} name
|
|
31
|
+
* @param {(p: string) => boolean} [existsSync]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function lanceTableExistsOnDisk(indexDir, name, existsSync = fs.existsSync) {
|
|
35
|
+
if (!indexDir || !INDEX_TABLE_NAMES.includes(name)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const tableDir = path.join(indexDir, `${name}.lance`);
|
|
40
|
+
const resolvedIndex = path.resolve(indexDir);
|
|
41
|
+
const resolvedTable = path.resolve(tableDir);
|
|
42
|
+
if (resolvedTable !== path.join(resolvedIndex, `${name}.lance`)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return existsSync(tableDir);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Open any INDEX_TABLE_NAMES entries not already in `tables`.
|
|
53
|
+
* Always re-lists tableNames — never assume a prior empty catalog is final.
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* db: { tableNames: () => Promise<string[]>, openTable: (name: string) => Promise<unknown>, close?: () => void },
|
|
57
|
+
* tables: Record<string, unknown>,
|
|
58
|
+
* indexDir: string,
|
|
59
|
+
* existsSync?: (p: string) => boolean,
|
|
60
|
+
* reconnect?: () => Promise<{ tableNames: () => Promise<string[]>, openTable: (name: string) => Promise<unknown>, close?: () => void }>,
|
|
61
|
+
* isCurrent?: () => boolean,
|
|
62
|
+
* log?: (msg: string) => void
|
|
63
|
+
* }} args
|
|
64
|
+
* @returns {Promise<{ db: object, tables: Record<string, unknown>, reconnected: boolean, aborted: boolean }>}
|
|
65
|
+
*/
|
|
66
|
+
export async function openMissingIndexTables({
|
|
67
|
+
db,
|
|
68
|
+
tables,
|
|
69
|
+
indexDir,
|
|
70
|
+
existsSync = fs.existsSync,
|
|
71
|
+
reconnect,
|
|
72
|
+
isCurrent = () => true,
|
|
73
|
+
log = (msg) => console.error(msg)
|
|
74
|
+
}) {
|
|
75
|
+
const stillCurrent = () => {
|
|
76
|
+
try {
|
|
77
|
+
return isCurrent() !== false;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const missing = INDEX_TABLE_NAMES.filter((name) => tables[name] == null);
|
|
84
|
+
if (missing.length === 0) {
|
|
85
|
+
return { db, tables, reconnected: false, aborted: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let tableNames = [];
|
|
89
|
+
try {
|
|
90
|
+
tableNames = await db.tableNames();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
log(`Failed to list index tables: ${e.message}`);
|
|
93
|
+
return { db, tables, reconnected: false, aborted: !stillCurrent() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!stillCurrent()) {
|
|
97
|
+
return { db, tables, reconnected: false, aborted: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listed = new Set(tableNames);
|
|
101
|
+
const onDiskButUnlisted = missing.filter(
|
|
102
|
+
(name) => !listed.has(name) && lanceTableExistsOnDisk(indexDir, name, existsSync)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
let reconnected = false;
|
|
106
|
+
let conn = db;
|
|
107
|
+
if (onDiskButUnlisted.length > 0 && typeof reconnect === "function") {
|
|
108
|
+
log("Index catalog missed on-disk tables; reconnecting to shared vector-index.");
|
|
109
|
+
for (const name of INDEX_TABLE_NAMES) {
|
|
110
|
+
delete tables[name];
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
conn = await reconnect();
|
|
114
|
+
reconnected = true;
|
|
115
|
+
if (!stillCurrent()) {
|
|
116
|
+
return { db: conn, tables, reconnected, aborted: true };
|
|
117
|
+
}
|
|
118
|
+
tableNames = await conn.tableNames();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log(`Failed to reconnect to index: ${e.message}`);
|
|
121
|
+
return { db: conn, tables, reconnected, aborted: !stillCurrent() };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!stillCurrent()) {
|
|
126
|
+
return { db: conn, tables, reconnected, aborted: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stillMissing = INDEX_TABLE_NAMES.filter((name) => tables[name] == null);
|
|
130
|
+
for (const name of stillMissing) {
|
|
131
|
+
if (!tableNames.includes(name)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
tables[name] = await conn.openTable(name);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
log(`Failed to open ${name} table: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
if (!stillCurrent()) {
|
|
140
|
+
return { db: conn, tables, reconnected, aborted: true };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { db: conn, tables, reconnected, aborted: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Process-local LanceDB connection + table handles.
|
|
149
|
+
* `tables` is a stable object (reset deletes keys, does not replace the object)
|
|
150
|
+
* so indexer.js can alias it for createTable/dropTable mutation.
|
|
151
|
+
*
|
|
152
|
+
* @param {{
|
|
153
|
+
* connect: (uri: string, options: object) => Promise<object>,
|
|
154
|
+
* indexDir: string,
|
|
155
|
+
* mkdirSync?: (p: string, opts?: object) => void,
|
|
156
|
+
* existsSync?: (p: string) => boolean,
|
|
157
|
+
* log?: (msg: string) => void,
|
|
158
|
+
* connectOptions?: object
|
|
159
|
+
* }} options
|
|
160
|
+
*/
|
|
161
|
+
export function createLanceTableCache(options) {
|
|
162
|
+
const {
|
|
163
|
+
connect,
|
|
164
|
+
indexDir,
|
|
165
|
+
mkdirSync,
|
|
166
|
+
existsSync = fs.existsSync,
|
|
167
|
+
log = (msg) => console.error(msg),
|
|
168
|
+
connectOptions = LANCE_CONNECT_OPTIONS
|
|
169
|
+
} = options;
|
|
170
|
+
|
|
171
|
+
const tables = {};
|
|
172
|
+
let db = null;
|
|
173
|
+
let connecting = null;
|
|
174
|
+
let refreshing = null;
|
|
175
|
+
let generation = 0;
|
|
176
|
+
|
|
177
|
+
function rememberPromise(getSlot, setSlot, pending) {
|
|
178
|
+
// Store the .finally() chain, not the raw pending. Discarding the derived
|
|
179
|
+
// promise leaves connect() failures as unhandledRejection (index.js exits).
|
|
180
|
+
const tracked = pending.finally(() => {
|
|
181
|
+
if (getSlot() === tracked) {
|
|
182
|
+
setSlot(null);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
setSlot(tracked);
|
|
186
|
+
return tracked;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function ensureDb() {
|
|
190
|
+
if (db) {
|
|
191
|
+
return db;
|
|
192
|
+
}
|
|
193
|
+
if (!connecting) {
|
|
194
|
+
const gen = generation;
|
|
195
|
+
rememberPromise(
|
|
196
|
+
() => connecting,
|
|
197
|
+
(value) => {
|
|
198
|
+
connecting = value;
|
|
199
|
+
},
|
|
200
|
+
(async () => {
|
|
201
|
+
if (typeof mkdirSync === "function") {
|
|
202
|
+
mkdirSync(indexDir, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
const conn = await connect(indexDir, connectOptions);
|
|
205
|
+
if (gen !== generation) {
|
|
206
|
+
try {
|
|
207
|
+
conn?.close?.();
|
|
208
|
+
} catch {
|
|
209
|
+
// stale connect after reset
|
|
210
|
+
}
|
|
211
|
+
return ensureDb();
|
|
212
|
+
}
|
|
213
|
+
db = conn;
|
|
214
|
+
return db;
|
|
215
|
+
})()
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return connecting;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function initDB() {
|
|
222
|
+
if (!refreshing) {
|
|
223
|
+
const gen = generation;
|
|
224
|
+
rememberPromise(
|
|
225
|
+
() => refreshing,
|
|
226
|
+
(value) => {
|
|
227
|
+
refreshing = value;
|
|
228
|
+
},
|
|
229
|
+
(async () => {
|
|
230
|
+
await ensureDb();
|
|
231
|
+
if (gen !== generation) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const result = await openMissingIndexTables({
|
|
235
|
+
db,
|
|
236
|
+
tables,
|
|
237
|
+
indexDir,
|
|
238
|
+
existsSync,
|
|
239
|
+
log,
|
|
240
|
+
isCurrent: () => gen === generation,
|
|
241
|
+
reconnect: async () => {
|
|
242
|
+
if (gen !== generation) {
|
|
243
|
+
return ensureDb();
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
db?.close?.();
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore close errors on a stale handle
|
|
249
|
+
}
|
|
250
|
+
if (gen !== generation) {
|
|
251
|
+
return ensureDb();
|
|
252
|
+
}
|
|
253
|
+
db = null;
|
|
254
|
+
return ensureDb();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
if (gen !== generation || result.aborted) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
db = result.db;
|
|
261
|
+
})()
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
await refreshing;
|
|
265
|
+
return { db, tables };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function isIndexReady(type = "emails") {
|
|
269
|
+
await initDB();
|
|
270
|
+
return tables[type] != null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function getOpenTable(type) {
|
|
274
|
+
await initDB();
|
|
275
|
+
return tables[type] || null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function reset() {
|
|
279
|
+
generation += 1;
|
|
280
|
+
const toClose = db;
|
|
281
|
+
db = null;
|
|
282
|
+
connecting = null;
|
|
283
|
+
refreshing = null;
|
|
284
|
+
for (const key of Object.keys(tables)) {
|
|
285
|
+
delete tables[key];
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
toClose?.close?.();
|
|
289
|
+
} catch {
|
|
290
|
+
// ignore
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
initDB,
|
|
296
|
+
isIndexReady,
|
|
297
|
+
getOpenTable,
|
|
298
|
+
reset,
|
|
299
|
+
get db() {
|
|
300
|
+
return db;
|
|
301
|
+
},
|
|
302
|
+
set db(value) {
|
|
303
|
+
db = value;
|
|
304
|
+
},
|
|
305
|
+
tables
|
|
306
|
+
};
|
|
307
|
+
}
|