@nomadamas/mailcrawl 0.1.4 → 0.1.5

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/dist/archive.d.ts CHANGED
@@ -4,21 +4,26 @@ export declare class Archive {
4
4
  readonly db: Database.Database;
5
5
  private embedder?;
6
6
  private lexical?;
7
+ private operationTail;
7
8
  private lexicalRebuildRequired;
8
9
  constructor(path?: string);
9
10
  close(): void;
10
11
  sync(messages: MailMessage[], policy?: ClassificationPolicy): Promise<SyncReport>;
12
+ private syncUnlocked;
11
13
  searchBm25(query: string, filters?: SearchFilters, limit?: number): Promise<SearchHit[]>;
12
14
  indexSemantic(): Promise<{
13
15
  embedded: number;
14
16
  reused: number;
15
17
  archiveRevision: string;
16
18
  }>;
19
+ private indexSemanticUnlocked;
17
20
  indexSemanticGeneration(root: string): Promise<{
18
21
  generation: string;
19
22
  embedded: number;
20
23
  reused: number;
21
24
  }>;
25
+ private indexSemanticGenerationUnlocked;
26
+ private cleanupSemanticGenerations;
22
27
  semanticGeneration(root: string): {
23
28
  generation: string;
24
29
  archiveRevision: string;
@@ -55,6 +60,7 @@ export declare class Archive {
55
60
  private removeMessage;
56
61
  private replaceMessageChunks;
57
62
  private revision;
63
+ private runExclusive;
58
64
  private getEmbedder;
59
65
  private searchLexicalTable;
60
66
  }
package/dist/archive.js CHANGED
@@ -1,22 +1,26 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { buildChunks } from "./chunk.js";
6
6
  import { normalizeMessage } from "./normalize.js";
7
- import { snippet } from "./util.js";
7
+ import { scopedId, snippet } from "./util.js";
8
8
  import { createEmbedder, embeddingModelName } from "./embedding.js";
9
9
  import { createLexicalAnalyzers, languagesForText, lexicalFields, LEXICAL_ANALYZER_VERSION } from "./lexical.js";
10
+ const RETAINED_SEMANTIC_GENERATIONS = 2;
11
+ const SEMANTIC_GENERATION_NAME = /^gen-[0-9a-f]{16}-[0-9]+$/;
10
12
  export class Archive {
11
13
  db;
12
14
  embedder;
13
15
  lexical;
16
+ operationTail = Promise.resolve();
14
17
  lexicalRebuildRequired;
15
18
  constructor(path = ":memory:") {
16
19
  this.db = new Database(path);
17
20
  this.db.pragma("journal_mode = WAL");
18
21
  this.db.pragma("foreign_keys = ON");
19
22
  migrate(this.db);
23
+ cleanupOrphanedSemanticRows(this.db);
20
24
  this.lexicalRebuildRequired = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get() === undefined;
21
25
  if (!this.lexicalRebuildRequired) {
22
26
  const row = this.db.prepare("SELECT version FROM lexical_index_meta WHERE id = 1").get();
@@ -32,14 +36,23 @@ export class Archive {
32
36
  this.db.close();
33
37
  }
34
38
  async sync(messages, policy = {}) {
39
+ return this.runExclusive(() => this.syncUnlocked(messages, policy));
40
+ }
41
+ async syncUnlocked(messages, policy = {}) {
42
+ for (const message of messages) {
43
+ if (typeof message.providerKey !== "string" || !message.providerKey.trim())
44
+ throw new Error("message provider identity is required");
45
+ }
35
46
  const excludedCategories = new Set((policy.excludedCategories ?? ["spam", "promotions"]).map(normalizeCategory));
36
47
  const normalized = (await Promise.all(messages.map(normalizeMessage))).map((message) => ({
37
48
  ...message,
38
- messageId: `${message.accountId}:${message.messageId}`,
39
- threadId: `${message.accountId}:${message.threadId}`,
40
- providerKey: `${message.accountId}:${message.providerKey}`,
41
- inReplyTo: message.inReplyTo ? `${message.accountId}:${message.inReplyTo}` : undefined,
49
+ messageId: scopedId(message.accountId, message.mailbox, message.messageId),
50
+ threadId: scopedId(message.accountId, message.mailbox, message.threadId),
51
+ providerKey: scopedId(message.accountId, message.mailbox, message.providerKey),
52
+ inReplyTo: message.inReplyTo ? scopedId(message.accountId, message.mailbox, message.inReplyTo) : undefined,
42
53
  }));
54
+ validateIdentities(normalized);
55
+ validateStoredIdentities(this.db, normalized);
43
56
  const excluded = normalized.filter((message) => message.categories.some((category) => excludedCategories.has(category)));
44
57
  const included = normalized.filter((message) => !message.categories.some((category) => excludedCategories.has(category)));
45
58
  const existing = this.db.prepare("SELECT provider_key, normalized_hash FROM messages").all();
@@ -126,18 +139,29 @@ export class Archive {
126
139
  .slice(0, limit).map(({ hit, score }) => ({ ...hit, score }));
127
140
  }
128
141
  async indexSemantic() {
142
+ return this.runExclusive(() => this.indexSemanticUnlocked());
143
+ }
144
+ async indexSemanticUnlocked() {
145
+ const completeQueueRow = this.db.prepare("UPDATE embedding_queue SET state = 'complete' WHERE chunk_id = ?");
146
+ const reconcile = this.db.transaction(() => {
147
+ this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id NOT IN (SELECT chunk_id FROM chunks)").run();
148
+ this.db.prepare(`INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts)
149
+ SELECT chunk_id, content_hash, 'pending', 0 FROM chunks
150
+ WHERE chunk_id NOT IN (SELECT chunk_id FROM embedding_queue)`).run();
151
+ });
152
+ reconcile();
129
153
  const rows = this.db.prepare("SELECT chunk_id, text, content_hash FROM chunks ORDER BY chunk_id").all();
130
154
  let embedded = 0;
131
155
  let reused = 0;
132
156
  const upsert = this.db.prepare(`INSERT INTO semantic_vectors
133
157
  (chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)
134
158
  ON CONFLICT(chunk_id) DO UPDATE SET content_hash=excluded.content_hash, model=excluded.model, vector=excluded.vector`);
135
- const embedder = await this.getEmbedder();
136
159
  const pending = [];
137
160
  const transaction = this.db.transaction(() => {
138
161
  for (const row of rows) {
139
162
  const old = this.db.prepare("SELECT content_hash, model FROM semantic_vectors WHERE chunk_id = ?").get(row.chunk_id);
140
163
  if (old?.content_hash === row.content_hash && old.model === embeddingModelName()) {
164
+ completeQueueRow.run(row.chunk_id);
141
165
  reused++;
142
166
  continue;
143
167
  }
@@ -147,10 +171,12 @@ export class Archive {
147
171
  transaction();
148
172
  if (!pending.length)
149
173
  return { embedded, reused, archiveRevision: this.revision() };
174
+ const embedder = await this.getEmbedder();
150
175
  const vectors = await embedder.embedDocuments(pending.map((row) => row.text));
151
176
  const write = this.db.transaction(() => {
152
177
  for (const [index, row] of pending.entries()) {
153
178
  upsert.run(row.chunk_id, row.content_hash, embeddingModelName(), JSON.stringify(vectors[index]));
179
+ completeQueueRow.run(row.chunk_id);
154
180
  embedded++;
155
181
  }
156
182
  });
@@ -158,29 +184,65 @@ export class Archive {
158
184
  return { embedded, reused, archiveRevision: this.revision() };
159
185
  }
160
186
  async indexSemanticGeneration(root) {
187
+ return this.runExclusive(() => this.indexSemanticGenerationUnlocked(root));
188
+ }
189
+ async indexSemanticGenerationUnlocked(root) {
161
190
  const currentPath = join(root, "CURRENT");
162
191
  const generationRoot = join(root, "generations");
192
+ const previousVectors = this.db.prepare("SELECT chunk_id, content_hash, model, vector FROM semantic_vectors ORDER BY chunk_id").all();
193
+ const previousQueue = this.db.prepare("SELECT chunk_id, content_hash, state, attempts FROM embedding_queue ORDER BY chunk_id").all();
163
194
  mkdirSync(generationRoot, { recursive: true });
164
195
  const generation = `gen-${this.revision().slice(0, 16)}-${Date.now()}`;
165
196
  const staging = join(generationRoot, `.${generation}.staging`);
197
+ const publishedGeneration = join(generationRoot, generation);
166
198
  mkdirSync(staging);
167
199
  try {
168
- const report = await this.indexSemantic();
169
- const vectors = this.db.prepare("SELECT chunk_id, content_hash, vector FROM semantic_vectors ORDER BY chunk_id").all();
200
+ const report = await this.indexSemanticUnlocked();
201
+ const vectors = this.db.prepare(`SELECT v.chunk_id, v.content_hash, v.vector
202
+ FROM semantic_vectors v JOIN chunks c ON c.chunk_id = v.chunk_id
203
+ ORDER BY v.chunk_id`).all();
170
204
  writeFileSync(join(staging, "manifest.json"), JSON.stringify({
171
205
  archiveRevision: this.revision(), vectors, model: embeddingModelName(),
172
206
  }));
173
- renameSync(staging, join(generationRoot, generation));
207
+ renameSync(staging, publishedGeneration);
174
208
  const pointer = join(root, `.CURRENT.${process.pid}`);
175
209
  writeFileSync(pointer, `${generation}\n`);
176
210
  renameSync(pointer, currentPath);
211
+ this.cleanupSemanticGenerations(generationRoot, generation);
177
212
  return { generation, embedded: report.embedded, reused: report.reused };
178
213
  }
179
214
  catch (error) {
180
215
  rmSync(staging, { recursive: true, force: true });
216
+ rmSync(publishedGeneration, { recursive: true, force: true });
217
+ const restore = this.db.transaction(() => {
218
+ this.db.exec("DELETE FROM semantic_vectors; DELETE FROM embedding_queue;");
219
+ const restoreVector = this.db.prepare("INSERT INTO semantic_vectors(chunk_id, content_hash, model, vector) VALUES (?, ?, ?, ?)");
220
+ for (const row of previousVectors)
221
+ restoreVector.run(row.chunk_id, row.content_hash, row.model, row.vector);
222
+ const restoreQueue = this.db.prepare("INSERT INTO embedding_queue(chunk_id, content_hash, state, attempts) VALUES (?, ?, ?, ?)");
223
+ for (const row of previousQueue)
224
+ restoreQueue.run(row.chunk_id, row.content_hash, row.state, row.attempts);
225
+ });
226
+ restore();
181
227
  throw error;
182
228
  }
183
229
  }
230
+ cleanupSemanticGenerations(generationRoot, activeGeneration) {
231
+ const generations = readdirSync(generationRoot, { withFileTypes: true })
232
+ .filter((entry) => entry.isDirectory() && SEMANTIC_GENERATION_NAME.test(entry.name))
233
+ .map((entry) => entry.name)
234
+ .sort((left, right) => {
235
+ const leftTimestamp = Number(left.slice(left.lastIndexOf("-") + 1));
236
+ const rightTimestamp = Number(right.slice(right.lastIndexOf("-") + 1));
237
+ return rightTimestamp - leftTimestamp || right.localeCompare(left);
238
+ });
239
+ const retained = new Set(generations.slice(0, RETAINED_SEMANTIC_GENERATIONS));
240
+ retained.add(activeGeneration);
241
+ for (const generation of generations) {
242
+ if (!retained.has(generation))
243
+ rmSync(join(generationRoot, generation), { recursive: true, force: true });
244
+ }
245
+ }
184
246
  semanticGeneration(root) {
185
247
  const generation = readFileSync(join(root, "CURRENT"), "utf8").trim();
186
248
  const manifest = JSON.parse(readFileSync(join(root, "generations", generation, "manifest.json"), "utf8"));
@@ -315,17 +377,14 @@ export class Archive {
315
377
  }
316
378
  }
317
379
  removeMessage(message) {
318
- const stored = this.db.prepare("SELECT message_id FROM messages WHERE provider_key = ?").get(message.providerKey);
319
- if (!stored)
320
- return;
321
- const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = ?").all(stored.message_id);
380
+ const rows = this.db.prepare("SELECT rowid FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?)").all(message.providerKey);
322
381
  for (const row of rows)
323
382
  this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
324
383
  for (const language of lexicalFields())
325
- this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(stored.message_id);
326
- this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
327
- this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(stored.message_id);
328
- this.db.prepare("DELETE FROM messages WHERE message_id = ?").run(stored.message_id);
384
+ this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))`).run(message.providerKey);
385
+ this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
386
+ this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = (SELECT message_id FROM messages WHERE provider_key = ?))").run(message.providerKey);
387
+ this.db.prepare("DELETE FROM messages WHERE provider_key = ?").run(message.providerKey);
329
388
  }
330
389
  replaceMessageChunks(message, analyzedChunks) {
331
390
  const old = this.db.prepare("SELECT rowid, chunk_id FROM chunks WHERE message_id = ?").all(message.messageId);
@@ -333,6 +392,8 @@ export class Archive {
333
392
  this.db.prepare("DELETE FROM chunks_fts WHERE rowid = ?").run(row.rowid);
334
393
  for (const language of lexicalFields())
335
394
  this.db.prepare(`DELETE FROM chunks_fts_${language} WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)`).run(message.messageId);
395
+ this.db.prepare("DELETE FROM embedding_queue WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
396
+ this.db.prepare("DELETE FROM semantic_vectors WHERE chunk_id IN (SELECT chunk_id FROM chunks WHERE message_id = ?)").run(message.messageId);
336
397
  this.db.prepare("DELETE FROM chunks WHERE message_id = ?").run(message.messageId);
337
398
  for (const chunk of buildChunks(message)) {
338
399
  const result = this.db.prepare(`INSERT INTO chunks
@@ -353,6 +414,19 @@ export class Archive {
353
414
  const rows = this.db.prepare("SELECT chunk_id, content_hash FROM chunks ORDER BY chunk_id").all();
354
415
  return createHash("sha256").update(rows.map((row) => `${row.chunk_id}\0${row.content_hash}`).join("\0")).digest("hex");
355
416
  }
417
+ async runExclusive(operation) {
418
+ const previous = this.operationTail;
419
+ let release = () => { };
420
+ const turn = new Promise((resolve) => { release = resolve; });
421
+ this.operationTail = previous.then(() => turn);
422
+ await previous;
423
+ try {
424
+ return await operation();
425
+ }
426
+ finally {
427
+ release();
428
+ }
429
+ }
356
430
  async getEmbedder() {
357
431
  this.embedder ??= await createEmbedder();
358
432
  return this.embedder;
@@ -426,6 +500,12 @@ function migrate(db) {
426
500
  db.exec(`ALTER TABLE messages ADD COLUMN ${column} TEXT NOT NULL DEFAULT '[]'`);
427
501
  }
428
502
  }
503
+ function cleanupOrphanedSemanticRows(db) {
504
+ db.prepare(`DELETE FROM embedding_queue
505
+ WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = embedding_queue.chunk_id)`).run();
506
+ db.prepare(`DELETE FROM semantic_vectors
507
+ WHERE NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.chunk_id = semantic_vectors.chunk_id)`).run();
508
+ }
429
509
  function literalFtsQuery(query) {
430
510
  return query.trim().split(/\s+/).map((term) => `"${term.replaceAll('"', '""')}"`).join(" ");
431
511
  }
@@ -462,6 +542,28 @@ function addFilters(clauses, params, filters) {
462
542
  function normalizeCategory(value) {
463
543
  return value.trim().toLocaleLowerCase().replace(/^category[_-]/, "").replace(/^label[_-]/, "");
464
544
  }
545
+ function validateIdentities(messages) {
546
+ const providerKeys = new Set();
547
+ const messageIds = new Set();
548
+ for (const message of messages) {
549
+ if (!message.providerKey)
550
+ throw new Error("message provider identity is required");
551
+ if (providerKeys.has(message.providerKey))
552
+ throw new Error("duplicate message provider identity");
553
+ providerKeys.add(message.providerKey);
554
+ if (messageIds.has(message.messageId))
555
+ throw new Error("duplicate message identity");
556
+ messageIds.add(message.messageId);
557
+ }
558
+ }
559
+ function validateStoredIdentities(db, messages) {
560
+ const byProviderKey = db.prepare("SELECT message_id FROM messages WHERE provider_key = ?");
561
+ for (const message of messages) {
562
+ const storedProvider = byProviderKey.get(message.providerKey);
563
+ if (storedProvider && storedProvider.message_id !== message.messageId)
564
+ throw new Error("provider identity already belongs to another message identity");
565
+ }
566
+ }
465
567
  function countExcluded(messages, excluded) {
466
568
  const counts = {};
467
569
  for (const message of messages) {
package/dist/cli/index.js CHANGED
@@ -177,13 +177,14 @@ program
177
177
  catch (error) {
178
178
  semantic = redactDiagnostic({ status: "stale", error: error instanceof Error ? error.message : String(error) });
179
179
  }
180
+ const semanticCommitted = typeof semantic === "object" && semantic !== null && "generation" in semantic;
180
181
  output({
181
182
  name: "mailcrawl",
182
183
  archive: `${dataDir}/archive.sqlite`,
183
184
  archivePresent: existsSync(`${dataDir}/archive.sqlite`),
184
185
  fts: "available",
185
186
  semantic,
186
- recommendation: semantic === "missing" ? "run sync, then index before semantic search" : "semantic index is committed",
187
+ recommendation: semanticCommitted ? "semantic index is committed" : "run sync, then index before semantic search",
187
188
  }, options.json);
188
189
  }
189
190
  finally {
package/dist/normalize.js CHANGED
@@ -26,7 +26,18 @@ export async function normalizeMessage(input) {
26
26
  const subject = input.subject.trim();
27
27
  const messageId = input.messageId || input.providerKey;
28
28
  const threadId = input.threadId || makeId(input.accountId, normalizeSubject(subject));
29
- const normalizedHash = hash(JSON.stringify([subject, input.from, input.to, input.cc, latest, quoted, input.labels, input.flags, input.classifications]));
29
+ const attachmentInputs = (attachments || []).map((attachment) => [
30
+ attachment.name,
31
+ attachment.mimeType,
32
+ attachment.size ?? null,
33
+ attachment.text ?? null,
34
+ attachment.contentHash ?? null,
35
+ ]);
36
+ const normalizedHash = hash(JSON.stringify([
37
+ subject, input.from, input.to, input.cc, latest, quoted,
38
+ input.labels, input.flags, input.classifications,
39
+ ...(attachmentInputs.length ? [attachmentInputs] : []),
40
+ ]));
30
41
  const categories = [...new Set([
31
42
  ...(input.classifications || []),
32
43
  ...(input.labels || []),
package/dist/util.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export declare function hash(value: string): string;
2
2
  export declare function normalizeSubject(subject: string): string;
3
3
  export declare function makeId(...parts: string[]): string;
4
+ export declare function scopedId(accountId: string, mailbox: string, value: string): string;
4
5
  export declare function snippet(text: string, query: string, maxChars?: number): string;
package/dist/util.js CHANGED
@@ -8,6 +8,9 @@ export function normalizeSubject(subject) {
8
8
  export function makeId(...parts) {
9
9
  return hash(parts.join("\u0000")).slice(0, 24);
10
10
  }
11
+ export function scopedId(accountId, mailbox, value) {
12
+ return makeId(accountId, mailbox, value);
13
+ }
11
14
  export function snippet(text, query, maxChars = 240) {
12
15
  const clean = text.trim();
13
16
  if (clean.length <= maxChars)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/mailcrawl",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Local Himalaya-backed incremental email indexing and hybrid search CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",