@wirecat/cli-core 0.487.0 → 0.489.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/mail/attachments.d.ts +15 -0
  25. package/dist/mail/attachments.js +38 -0
  26. package/dist/mail/locations.d.ts +28 -0
  27. package/dist/mail/locations.js +54 -0
  28. package/dist/mail/quick.d.ts +3 -1
  29. package/dist/mail/quick.js +9 -6
  30. package/dist/mail/resume.d.ts +6 -0
  31. package/dist/mail/retry.d.ts +23 -0
  32. package/dist/mail/retry.js +110 -0
  33. package/dist/mail/sync.d.ts +2 -3
  34. package/dist/mail/sync.js +85 -152
  35. package/dist/mail/testing/fake.d.ts +2 -0
  36. package/dist/mail/testing/fake.js +1 -1
  37. package/dist/meetings/event-store.d.ts +80 -2
  38. package/dist/meetings/event-store.js +65 -1
  39. package/dist/meetings/index.d.ts +2 -1
  40. package/dist/meetings/index.js +1 -0
  41. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  42. package/dist/meetings/testing/event-store-contract.js +114 -0
  43. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  44. package/dist/store/drivers/node-sqlite.js +30 -8
  45. package/dist/store/index.d.ts +1 -1
  46. package/dist/store/sqlite/accounts.js +1 -0
  47. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  48. package/dist/store/sqlite/calendar-events.js +89 -0
  49. package/dist/store/sqlite/calendar.js +126 -94
  50. package/dist/store/sqlite/email-locations.d.ts +24 -0
  51. package/dist/store/sqlite/email-locations.js +56 -0
  52. package/dist/store/sqlite/email-state.d.ts +2 -0
  53. package/dist/store/sqlite/email-state.js +28 -0
  54. package/dist/store/sqlite/emails.d.ts +13 -0
  55. package/dist/store/sqlite/emails.js +9 -29
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +7 -0
  58. package/dist/store/sqlite/schema.d.ts +72 -0
  59. package/dist/store/sqlite/schema.js +13 -0
  60. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
2
+ import { calendarText, entryInWindow, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
+ import { listEvents } from "./calendar-events.js";
3
4
  import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
4
5
  import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
5
6
  import { identityOf } from "./identities.js";
@@ -51,6 +52,8 @@ const entryOf = (row) => ({
51
52
  etag: str(row.etag),
52
53
  seenAt: int(row.seen_at),
53
54
  deletedAt: int(row.deleted_at),
55
+ createdAt: Number(row.created_at),
56
+ transparency: str(row.transparency),
54
57
  });
55
58
  const participantOf = (row) => ({
56
59
  entryId: Number(row.entry_id),
@@ -79,6 +82,7 @@ const reviewOf = (row) => ({
79
82
  id: Number(row.id),
80
83
  linkableType: String(row.linkable_type),
81
84
  linkableId: Number(row.linkable_id),
85
+ calendarId: int(row.calendar_id),
82
86
  entityType: String(row.entity_type),
83
87
  candidates: fromJson(row.candidates),
84
88
  reason: String(row.reason),
@@ -94,6 +98,8 @@ const attachmentOf = (row) => ({
94
98
  title: str(fromJson(row.metadata)?.title),
95
99
  documentId: int(row.to_id),
96
100
  });
101
+ const REVIEWS = "SELECT r.*, CASE WHEN r.linkable_type = 'calendar_entry' THEN " +
102
+ "(SELECT calendar_id FROM calendar_entries WHERE id = r.linkable_id) END AS calendar_id FROM link_reviews r";
97
103
  const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
98
104
  const flag = (value) => (value ? 1 : 0);
99
105
  const cursorKey = (calendarId) => `calendar:${calendarId}`;
@@ -448,6 +454,56 @@ export const eventStoreOver = (context) => {
448
454
  refreshEvent(Number(row.event_id), now);
449
455
  }
450
456
  };
457
+ const saveAll = (calendarId, entries, now) => {
458
+ calendarRow(calendarId);
459
+ // A series first, so an occurrence in the same page finds it.
460
+ const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
461
+ const ids = new Map();
462
+ const saved = ordered.map((input) => {
463
+ const entry = saveEntry(calendarId, input, now);
464
+ ids.set(input, entry.id);
465
+ return entry;
466
+ });
467
+ for (const entry of saved)
468
+ if (entry.kind === "series" && entry.status === "cancelled")
469
+ cancelOccurrences(entry.id, now);
470
+ const current = saved.map(({ id }) => entryOf(entryRow(id)));
471
+ for (const entry of current)
472
+ link(entry, now);
473
+ refreshAround(current.map(({ id }) => id), now);
474
+ return entries.map((input) => entryOf(entryRow(ids.get(input))));
475
+ };
476
+ const removeAll = (calendarId, externalIds, now) => {
477
+ calendarRow(calendarId);
478
+ const removed = database
479
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
480
+ AND status <> 'cancelled' AND deleted_at IS NULL`)
481
+ .all(calendarId, JSON.stringify(externalIds));
482
+ for (const row of removed) {
483
+ cancel(row, now);
484
+ database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
485
+ if (row.kind === "series")
486
+ cancelOccurrences(Number(row.id), now);
487
+ }
488
+ refreshAround(removed.map((row) => Number(row.id)), now);
489
+ return removed.length;
490
+ };
491
+ const putCursor = (calendarId, value, now) => {
492
+ const accountId = Number(calendarRow(calendarId).account_id);
493
+ if (value === null)
494
+ database
495
+ .prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
496
+ .run(accountId, cursorKey(calendarId));
497
+ else
498
+ database
499
+ .prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
500
+ ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
501
+ .run(accountId, cursorKey(calendarId), value, now, now);
502
+ };
503
+ const slotRows = (calendarId, series, window) => database
504
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND kind = 'occurrence' AND series_external_id = ?
505
+ AND original_starts_at >= ? AND original_starts_at < ? AND deleted_at IS NULL ORDER BY original_starts_at`)
506
+ .all(calendarId, series, window.from, window.to);
451
507
  return {
452
508
  saveCalendars: async (accountId, calendars, now) => write(database, () => {
453
509
  const upsert = database.prepare(`INSERT INTO calendars (account_id, external_id, title, timezone, access_role, primary_calendar, synced, seen_at,
@@ -468,115 +524,72 @@ export const eventStoreOver = (context) => {
468
524
  .prepare("UPDATE calendars SET synced = ?, updated_at = ? WHERE id = ?")
469
525
  .run(flag(synced), now, calendarId);
470
526
  }),
471
- saveEntries: async (calendarId, entries, now) => write(database, () => {
472
- calendarRow(calendarId);
473
- // A series first, so an occurrence in the same page finds it.
474
- const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
475
- const ids = new Map();
476
- const saved = ordered.map((input) => {
477
- const entry = saveEntry(calendarId, input, now);
478
- ids.set(input, entry.id);
479
- return entry;
480
- });
481
- for (const entry of saved)
482
- if (entry.kind === "series" && entry.status === "cancelled")
483
- cancelOccurrences(entry.id, now);
484
- const current = saved.map(({ id }) => entryOf(entryRow(id)));
485
- for (const entry of current)
486
- link(entry, now);
487
- refreshAround(current.map(({ id }) => id), now);
488
- return entries.map((input) => entryOf(entryRow(ids.get(input))));
489
- }),
490
- removeEntries: async (calendarId, externalIds, now) => write(database, () => {
491
- calendarRow(calendarId);
492
- const removed = database
493
- .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
494
- AND status <> 'cancelled' AND deleted_at IS NULL`)
495
- .all(calendarId, JSON.stringify(externalIds));
496
- for (const row of removed) {
497
- cancel(row, now);
498
- database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
499
- if (row.kind === "series")
500
- cancelOccurrences(Number(row.id), now);
501
- }
502
- refreshAround(removed.map((row) => Number(row.id)), now);
503
- return removed.length;
504
- }),
527
+ saveEntries: async (calendarId, entries, now) => write(database, () => saveAll(calendarId, entries, now)),
528
+ removeEntries: async (calendarId, externalIds, now) => write(database, () => removeAll(calendarId, externalIds, now)),
505
529
  markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
506
530
  const marked = database
507
- .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
508
- AND (seen_at IS NULL OR seen_at < ?)
509
- AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))
510
- RETURNING event_id`)
511
- .all(now, now, calendarId, seenBefore, window.from, window.to);
531
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NULL AND (seen_at IS NULL OR seen_at < ?)
532
+ AND min(coalesce(starts_at, original_starts_at), coalesce(original_starts_at, starts_at)) < ? AND (kind = 'series'
533
+ OR max(coalesce(ends_at, 0), coalesce(starts_at, 0) + 1, coalesce(original_starts_at, 0)) >= ?)`)
534
+ .all(calendarId, seenBefore, window.to, window.from)
535
+ .filter((row) => entryInWindow(entryOf(row), window));
536
+ const mark = database.prepare("UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE id = ?");
537
+ for (const row of marked)
538
+ mark.run(now, now, Number(row.id));
512
539
  for (const eventId of new Set(marked.map((row) => int(row.event_id))))
513
540
  if (eventId !== null)
514
541
  refreshEvent(eventId, now);
515
542
  return marked.length;
516
543
  }),
544
+ restoreUnseen: async (calendarId, windowAt, now) => write(database, () => {
545
+ const restored = database
546
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NOT NULL")
547
+ .all(calendarId)
548
+ .map(entryOf)
549
+ .filter((entry) => !entryInWindow(entry, windowAt(entry.deletedAt)));
550
+ const restore = database.prepare("UPDATE calendar_entries SET deleted_at = NULL, updated_at = ? WHERE id = ?");
551
+ for (const { id } of restored)
552
+ restore.run(now, id);
553
+ refreshAround(restored.map(({ id }) => id), now);
554
+ return restored.length;
555
+ }),
517
556
  entry: async (calendarId, externalId) => read(database, () => {
518
557
  const row = database
519
558
  .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ?")
520
559
  .get(calendarId, externalId);
521
560
  return row ? entryOf(row) : null;
522
561
  }),
562
+ slotsOf: async (calendarId, seriesExternalId, window) => read(database, () => slotRows(calendarId, seriesExternalId, window).map(entryOf)),
563
+ saveSlots: async (calendarId, groups, window, cursor, now) => write(database, () => {
564
+ let [saved, removed] = [0, 0];
565
+ const seen = database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?");
566
+ for (const { series, slots } of groups) {
567
+ const held = slotRows(calendarId, series, window);
568
+ if (slots === null) {
569
+ for (const row of held)
570
+ seen.run(now, Number(row.id));
571
+ continue;
572
+ }
573
+ if (slots.length)
574
+ saved += saveAll(calendarId, slots, now).length;
575
+ const given = new Set(slots.map(({ externalId }) => externalId));
576
+ const stale = held
577
+ .filter((row) => Number(row.exception) !== 1 && row.status !== "cancelled" && !given.has(String(row.external_id)))
578
+ .map((row) => String(row.external_id));
579
+ if (stale.length)
580
+ removed += removeAll(calendarId, stale, now);
581
+ }
582
+ putCursor(calendarId, cursor, now);
583
+ return { saved, removed };
584
+ }),
523
585
  cursor: async (calendarId) => read(database, () => {
524
586
  const { account_id } = calendarRow(calendarId);
525
587
  return str(database
526
588
  .prepare("SELECT value FROM sync_cursors WHERE account_id = ? AND key = ?")
527
589
  .get(Number(account_id), cursorKey(calendarId))?.value);
528
590
  }),
529
- setCursor: async (calendarId, value, now) => write(database, () => {
530
- const accountId = Number(calendarRow(calendarId).account_id);
531
- if (value === null)
532
- database
533
- .prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
534
- .run(accountId, cursorKey(calendarId));
535
- else
536
- database
537
- .prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
538
- ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
539
- .run(accountId, cursorKey(calendarId), value, now, now);
540
- }),
541
- events: async (filter = {}) => {
542
- const clauses = ["e.deleted_at IS NULL"];
543
- const params = [];
544
- const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
545
- const dated = (column, date, instant, at) => {
546
- if (at === undefined)
547
- return;
548
- if (date === undefined) {
549
- clauses.push(instant);
550
- params.push(at);
551
- return;
552
- }
553
- clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
554
- params.push(date, at);
555
- };
556
- dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
557
- dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
558
- if (filter.calendarId !== undefined) {
559
- clauses.push(entries("ce.calendar_id = ?"));
560
- params.push(filter.calendarId);
561
- }
562
- if (filter.accountId !== undefined) {
563
- clauses.push(entries("c.account_id = ?"));
564
- params.push(filter.accountId);
565
- }
566
- if (filter.personId !== undefined) {
567
- clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
568
- "WHERE p.entry_id = ce.id AND il.person_id = ?)"));
569
- params.push(filter.personId);
570
- }
571
- const limit = filter.limit ?? 100;
572
- const offset = filter.offset ?? 0;
573
- if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
574
- throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
575
- return database
576
- .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
577
- .all(...params, limit, offset)
578
- .map(eventOf);
579
- },
591
+ setCursor: async (calendarId, value, now) => write(database, () => putCursor(calendarId, value, now)),
592
+ events: async (filter = {}) => listEvents(database, filter),
580
593
  event: async (id) => read(database, () => {
581
594
  const row = database.prepare("SELECT * FROM events WHERE id = ?").get(id);
582
595
  if (!row)
@@ -605,6 +618,25 @@ export const eventStoreOver = (context) => {
605
618
  .map(attachmentOf),
606
619
  };
607
620
  }),
621
+ historyOf: async (eventIds, calendarIds, since) => read(database, () => {
622
+ const entries = database
623
+ .prepare(`SELECT * FROM calendar_entries WHERE event_id IN (SELECT value FROM json_each(?))
624
+ AND calendar_id IN (SELECT value FROM json_each(?)) ORDER BY id`)
625
+ .all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
626
+ .map(entryOf);
627
+ const revisions = database
628
+ .prepare(`SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) AND created_at >= ?
629
+ ORDER BY created_at, id`)
630
+ .all(JSON.stringify(entries.map(({ id }) => id)), since)
631
+ .map(revisionOf);
632
+ return { entries, revisions };
633
+ }),
634
+ participantsOf: async (eventIds, calendarIds) => read(database, () => database
635
+ .prepare(`SELECT p.*, ce.event_id FROM calendar_entry_participants p JOIN calendar_entries ce ON ce.id = p.entry_id
636
+ WHERE ce.event_id IN (SELECT value FROM json_each(?)) AND ce.calendar_id IN (SELECT value FROM json_each(?))
637
+ ORDER BY ce.event_id, p.entry_id, p.email`)
638
+ .all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
639
+ .map((row) => ({ ...participantOf(row), eventId: Number(row.event_id) }))),
608
640
  linkEntry: async (entryId, eventId, now) => write(database, () => {
609
641
  const row = database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(entryId);
610
642
  if (!row)
@@ -636,11 +668,11 @@ export const eventStoreOver = (context) => {
636
668
  });
637
669
  }),
638
670
  reviews: async (filter = {}) => database
639
- .prepare(`SELECT * FROM link_reviews${filter.status ? " WHERE status = ?" : ""} ORDER BY created_at, id`)
671
+ .prepare(`${REVIEWS}${filter.status ? " WHERE r.status = ?" : ""} ORDER BY r.created_at, r.id`)
640
672
  .all(...(filter.status ? [filter.status] : []))
641
673
  .map(reviewOf),
642
674
  resolveReview: async (id, decision, now) => write(database, () => {
643
- const row = database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id);
675
+ const row = database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id);
644
676
  if (!row)
645
677
  throw new CliError("not_found", `no review ${id} in the local store`);
646
678
  const review = reviewOf(row);
@@ -710,7 +742,7 @@ export const eventStoreOver = (context) => {
710
742
  database
711
743
  .prepare("UPDATE link_reviews SET status = ?, chosen_id = ?, decided_by = 'owner', decided_at = ?, updated_at = ? WHERE id = ?")
712
744
  .run(chosen === null ? "apart" : "linked", chosen, now, now, id);
713
- return reviewOf(database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id));
745
+ return reviewOf(database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id));
714
746
  }),
715
747
  };
716
748
  };
@@ -0,0 +1,24 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** Where an IMAP server holds an email: `folder` is the mailbox's external id. */
3
+ export interface EmailLocationInput {
4
+ folder: string;
5
+ uidvalidity: string;
6
+ uid: number;
7
+ }
8
+ /** One email of a folder as a sync sees it there. */
9
+ export interface FolderEmail {
10
+ uid: number;
11
+ externalId: string;
12
+ receivedAt: number | null;
13
+ read: boolean | null;
14
+ flagged: boolean | null;
15
+ /** Its locations in every folder, and the folders it is a member of: an email is gone only when all are lost. */
16
+ locations: number;
17
+ folders: number;
18
+ }
19
+ export declare const locate: (database: CacheDatabase, accountId: number, emailId: number, where: EmailLocationInput) => {
20
+ changes: number;
21
+ };
22
+ export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
23
+ export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
24
+ export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
@@ -0,0 +1,56 @@
1
+ export const locate = (database, accountId, emailId, where) => database
2
+ .prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
3
+ SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
4
+ .run(where.uidvalidity, where.uid, emailId, accountId, where.folder);
5
+ export const folderEmails = (database, accountId, folder, uidvalidity) => database
6
+ .prepare(`SELECT l.uid, e.external_id, coalesce(e.received_at, e.sent_at) AS at, e.read, e.flagged,
7
+ (SELECT count(*) FROM email_locations x WHERE x.email_id = e.id) AS locations,
8
+ (SELECT count(*) FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
9
+ WHERE m.email_id = e.id AND b.kind IS NULL) AS folders
10
+ FROM email_locations l JOIN emails e ON e.id = l.email_id
11
+ WHERE l.mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)
12
+ AND l.uidvalidity = ? AND e.deleted_at IS NULL
13
+ ORDER BY l.uid`)
14
+ .all(accountId, folder, uidvalidity)
15
+ .map((row) => ({
16
+ uid: Number(row.uid),
17
+ externalId: String(row.external_id),
18
+ receivedAt: row.at === null ? null : Number(row.at),
19
+ read: row.read === null ? null : Boolean(row.read),
20
+ flagged: row.flagged === null ? null : Boolean(row.flagged),
21
+ locations: Number(row.locations),
22
+ folders: Number(row.folders),
23
+ }));
24
+ export const setLocations = (database, accountId, folder, uidvalidity, found) => {
25
+ database
26
+ .prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
27
+ AND mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)`)
28
+ .run(uidvalidity, accountId, folder);
29
+ const lookup = database.prepare("SELECT id FROM emails WHERE account_id = ? AND external_id = ?");
30
+ let count = 0;
31
+ for (const [externalId, uids] of found) {
32
+ const row = lookup.get(accountId, externalId);
33
+ if (!row)
34
+ continue;
35
+ for (const uid of uids)
36
+ locate(database, accountId, Number(row.id), { folder, uidvalidity, uid });
37
+ count++;
38
+ }
39
+ return count;
40
+ };
41
+ export const forgetLocations = (database, accountId, folder, uidvalidity, uids) => {
42
+ const box = database
43
+ .prepare("SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?")
44
+ .get(accountId, folder);
45
+ if (!box)
46
+ return;
47
+ const emails = database
48
+ .prepare(`DELETE FROM email_locations WHERE mailbox_id = ? AND uidvalidity = ?
49
+ AND uid IN (SELECT value FROM json_each(?)) RETURNING email_id`)
50
+ .all(Number(box.id), uidvalidity, JSON.stringify(uids))
51
+ .map((row) => Number(row.email_id));
52
+ const leave = database.prepare(`DELETE FROM email_mailboxes WHERE email_id = ? AND mailbox_id = ?
53
+ AND NOT EXISTS (SELECT 1 FROM email_locations WHERE email_id = ? AND mailbox_id = ?)`);
54
+ for (const emailId of new Set(emails))
55
+ leave.run(emailId, Number(box.id), emailId, Number(box.id));
56
+ };
@@ -6,3 +6,5 @@ export declare const setEmailFlags: (database: CacheDatabase, accountId: number,
6
6
  read?: boolean;
7
7
  flagged?: boolean;
8
8
  }>, now: number) => number;
9
+ export declare const markEmailsDeleted: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
10
+ export declare const markEmailsRestored: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
@@ -1,4 +1,6 @@
1
+ import { dropAttachmentChunks } from "./email-index.js";
1
2
  import { flag } from "./events.js";
3
+ import { dropUnusedVectors } from "./vector-index.js";
2
4
  export const recountEmailThread = (database, threadId, now) => database
3
5
  .prepare(`UPDATE email_threads SET
4
6
  emails_count = (SELECT count(*) FROM emails WHERE email_thread_id = ? AND deleted_at IS NULL),
@@ -23,3 +25,29 @@ export const setEmailFlags = (database, accountId, flags, now) => {
23
25
  }
24
26
  return changed;
25
27
  };
28
+ export const markEmailsDeleted = (database, accountId, externalIds, now) => {
29
+ const mark = database.prepare(`UPDATE emails SET deleted_at = ?, body_text = NULL, body_html = NULL, snippet = NULL, updated_at = ?
30
+ WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL
31
+ RETURNING id, email_thread_id`);
32
+ const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
33
+ const unlocate = database.prepare("DELETE FROM email_locations WHERE email_id = ?");
34
+ const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
35
+ const pieces = dropAttachmentChunks(database, Number(r.id));
36
+ forget.run(Number(r.id));
37
+ unlocate.run(Number(r.id));
38
+ dropUnusedVectors(database, pieces);
39
+ return Number(r.email_thread_id);
40
+ }));
41
+ for (const id of new Set(threads))
42
+ recountEmailThread(database, id, now);
43
+ return threads.length;
44
+ };
45
+ export const markEmailsRestored = (database, accountId, externalIds, now) => {
46
+ const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
47
+ WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
48
+ RETURNING email_thread_id`);
49
+ const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
50
+ for (const id of new Set(threads))
51
+ recountEmailThread(database, id, now);
52
+ return threads.length;
53
+ };
@@ -3,9 +3,11 @@ import type { Attachment, Metadata } from "../../meetings/index.js";
3
3
  import type { Stemmer, Stemmers } from "../../search/stem.js";
4
4
  import type { CacheDatabase } from "../driver.js";
5
5
  import type { ConversationSummary, StoredHit } from "../types.js";
6
+ import { type EmailLocationInput, type FolderEmail } from "./email-locations.js";
6
7
  import { type MailRulesStore } from "./mail-rules.js";
7
8
  import { type AttachmentInput } from "./meetings.js";
8
9
  import type { StoreContext } from "./open.js";
10
+ export type { EmailLocationInput, FolderEmail };
9
11
  export interface EmailAddress {
10
12
  address: string;
11
13
  name: string | null;
@@ -43,6 +45,8 @@ export interface EmailInput {
43
45
  mailRuleId?: number | null;
44
46
  /** Added to the mailboxes the email is already in; one scan sees one folder, so none is taken away. */
45
47
  mailboxes?: MailboxInput[];
48
+ /** Added to the locations the email has; a UID already known in that folder now points here. */
49
+ locations?: EmailLocationInput[];
46
50
  /** Replaced when given; the text is word-indexed. */
47
51
  attachments?: AttachmentInput[];
48
52
  }
@@ -228,6 +232,15 @@ export interface MailStore {
228
232
  read?: boolean;
229
233
  flagged?: boolean;
230
234
  }>, now: number): Promise<number>;
235
+ /** The live emails whose location in `folder` has this UIDVALIDITY, oldest UID first. */
236
+ folderEmails(accountId: number, folder: string, uidvalidity: string): Promise<FolderEmail[]>;
237
+ /**
238
+ * Records where emails the store holds are in `folder`, by Message-ID; one it does not hold is left out. Locations
239
+ * of the folder under another UIDVALIDITY are dropped: its UIDs mean nothing now.
240
+ */
241
+ setLocations(accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>): Promise<number>;
242
+ /** Forgets that these UIDs of `folder` hold anything; an email left with no location there leaves the folder too. */
243
+ forgetLocations(accountId: number, folder: string, uidvalidity: string, uids: number[]): Promise<void>;
231
244
  /** Marks emails gone at the source and drops their text; saving one again brings it back. Answers how many changed. */
232
245
  markDeleted(accountId: number, externalIds: string[], now: number): Promise<number>;
233
246
  /**
@@ -2,7 +2,8 @@ import { formatLocator } from "../../domain/locator.js";
2
2
  import { formatReference } from "../../domain/references.js";
3
3
  import { fold, normalize } from "../normalize.js";
4
4
  import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
5
- import { recountEmailThread as recount, setEmailFlags } from "./email-state.js";
5
+ import { folderEmails, forgetLocations, locate, setLocations, } from "./email-locations.js";
6
+ import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
6
7
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
7
8
  import { folded } from "./folded.js";
8
9
  import { ensurePerson } from "./identities.js";
@@ -228,6 +229,8 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
228
229
  }
229
230
  for (const box of mail.mailboxes ?? [])
230
231
  join(database, emailId, mailboxPk(database, accountId, box, now), now);
232
+ for (const where of mail.locations ?? [])
233
+ locate(database, accountId, emailId, where);
231
234
  if (mail.attachments !== undefined) {
232
235
  const before = dropAttachmentChunks(database, emailId);
233
236
  const positions = mail.attachments.map(({ position }) => position);
@@ -545,34 +548,11 @@ export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) =>
545
548
  return setEmailFlags(database, accountId, flags, now);
546
549
  });
547
550
  },
548
- async markDeleted(accountId, externalIds, now) {
549
- return write(database, () => {
550
- const mark = database.prepare(`UPDATE emails SET deleted_at = ?, body_text = NULL, body_html = NULL, snippet = NULL, updated_at = ?
551
- WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL
552
- RETURNING id, email_thread_id`);
553
- const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
554
- const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
555
- const pieces = dropAttachmentChunks(database, Number(r.id));
556
- forget.run(Number(r.id));
557
- dropUnusedVectors(database, pieces);
558
- return Number(r.email_thread_id);
559
- }));
560
- for (const id of new Set(threads))
561
- recount(database, id, now);
562
- return threads.length;
563
- });
564
- },
565
- async markRestored(accountId, externalIds, now) {
566
- return write(database, () => {
567
- const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
568
- WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
569
- RETURNING email_thread_id`);
570
- const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
571
- for (const id of new Set(threads))
572
- recount(database, id, now);
573
- return threads.length;
574
- });
575
- },
551
+ folderEmails: async (accountId, folder, uidvalidity) => folderEmails(database, accountId, folder, uidvalidity),
552
+ setLocations: async (accountId, folder, uidvalidity, found) => write(database, () => setLocations(database, accountId, folder, uidvalidity, found)),
553
+ forgetLocations: async (accountId, folder, uidvalidity, uids) => write(database, () => forgetLocations(database, accountId, folder, uidvalidity, uids)),
554
+ markDeleted: async (accountId, externalIds, now) => write(database, () => markEmailsDeleted(database, accountId, externalIds, now)),
555
+ markRestored: async (accountId, externalIds, now) => write(database, () => markEmailsRestored(database, accountId, externalIds, now)),
576
556
  async chunksToEmbed(model, { after, limit }) {
577
557
  drainEmailIndex(database, stemmerFor);
578
558
  const file = database.prepare("SELECT text FROM attachments WHERE id = ?");
@@ -52,6 +52,7 @@ export const MANIFEST = [
52
52
  { name: "20261029181000_version-37-contact-mention-triggers", version: 37, minCompatible: 34 },
53
53
  { name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
54
54
  { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
55
+ { name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
55
56
  ];
56
57
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
57
58
  const migrations = [];
@@ -830,5 +830,12 @@ export const GENERATED = [
830
830
  "CREATE TRIGGER email_threads_subject_folded_au AFTER UPDATE OF subject ON email_threads\n WHEN new.subject IS NOT old.subject AND new.subject_folded IS old.subject_folded BEGIN\n UPDATE email_threads SET subject_folded = NULL WHERE id = new.id;\nEND;",
831
831
  "CREATE TRIGGER mailboxes_name_folded_au AFTER UPDATE OF name ON mailboxes\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE mailboxes SET name_folded = NULL WHERE id = new.id;\nEND;"
832
832
  ]
833
+ },
834
+ {
835
+ "name": "20261030114008_version-39-email-locations",
836
+ "statements": [
837
+ "CREATE TABLE `email_locations` (\n\t`mailbox_id` integer NOT NULL,\n\t`uidvalidity` text NOT NULL,\n\t`uid` integer NOT NULL,\n\t`email_id` integer NOT NULL,\n\tCONSTRAINT `email_locations_pk` PRIMARY KEY(`mailbox_id`, `uidvalidity`, `uid`),\n\tCONSTRAINT `fk_email_locations_mailbox_id_mailboxes_id_fk` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes`(`id`),\n\tCONSTRAINT `fk_email_locations_email_id_emails_id_fk` FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`)\n);",
838
+ "CREATE INDEX `email_locations_by_email_id` ON `email_locations` (`email_id`);"
839
+ ]
833
840
  }
834
841
  ];
@@ -6172,6 +6172,78 @@ export declare const emailMailboxes: import("drizzle-orm/sqlite-core").SQLiteTab
6172
6172
  dialect: 'sqlite';
6173
6173
  isAlias: false;
6174
6174
  }>;
6175
+ export declare const emailLocations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6176
+ name: "email_locations";
6177
+ schema: undefined;
6178
+ columns: {
6179
+ mailboxId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6180
+ name: string;
6181
+ tableName: "email_locations";
6182
+ dataType: "number int53";
6183
+ data: number;
6184
+ driverParam: number;
6185
+ notNull: true;
6186
+ hasDefault: false;
6187
+ isPrimaryKey: false;
6188
+ isAutoincrement: false;
6189
+ hasRuntimeDefault: false;
6190
+ enumValues: undefined;
6191
+ baseColumn: never;
6192
+ identity: undefined;
6193
+ generated: undefined;
6194
+ }, {}>;
6195
+ uidvalidity: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6196
+ name: string;
6197
+ tableName: "email_locations";
6198
+ dataType: "string";
6199
+ data: string;
6200
+ driverParam: string;
6201
+ notNull: true;
6202
+ hasDefault: false;
6203
+ isPrimaryKey: false;
6204
+ isAutoincrement: false;
6205
+ hasRuntimeDefault: false;
6206
+ enumValues: [string, ...string[]];
6207
+ baseColumn: never;
6208
+ identity: undefined;
6209
+ generated: undefined;
6210
+ }, {}>;
6211
+ uid: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6212
+ name: string;
6213
+ tableName: "email_locations";
6214
+ dataType: "number int53";
6215
+ data: number;
6216
+ driverParam: number;
6217
+ notNull: true;
6218
+ hasDefault: false;
6219
+ isPrimaryKey: false;
6220
+ isAutoincrement: false;
6221
+ hasRuntimeDefault: false;
6222
+ enumValues: undefined;
6223
+ baseColumn: never;
6224
+ identity: undefined;
6225
+ generated: undefined;
6226
+ }, {}>;
6227
+ emailId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6228
+ name: string;
6229
+ tableName: "email_locations";
6230
+ dataType: "number int53";
6231
+ data: number;
6232
+ driverParam: number;
6233
+ notNull: true;
6234
+ hasDefault: false;
6235
+ isPrimaryKey: false;
6236
+ isAutoincrement: false;
6237
+ hasRuntimeDefault: false;
6238
+ enumValues: undefined;
6239
+ baseColumn: never;
6240
+ identity: undefined;
6241
+ generated: undefined;
6242
+ }, {}>;
6243
+ };
6244
+ dialect: 'sqlite';
6245
+ isAlias: false;
6246
+ }>;
6175
6247
  export declare const emailIndexPending: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6176
6248
  name: "email_index_pending";
6177
6249
  schema: undefined;
@@ -695,6 +695,19 @@ export const emailMailboxes = sqliteTable("email_mailboxes", {
695
695
  primaryKey({ columns: [table.emailId, table.mailboxId] }),
696
696
  index("email_mailboxes_by_mailbox_id").on(table.mailboxId),
697
697
  ]);
698
+ export const emailLocations = sqliteTable("email_locations", {
699
+ mailboxId: integer("mailbox_id")
700
+ .notNull()
701
+ .references(() => mailboxes.id),
702
+ uidvalidity: text("uidvalidity").notNull(),
703
+ uid: integer("uid").notNull(),
704
+ emailId: integer("email_id")
705
+ .notNull()
706
+ .references(() => emails.id),
707
+ }, (table) => [
708
+ primaryKey({ columns: [table.mailboxId, table.uidvalidity, table.uid] }),
709
+ index("email_locations_by_email_id").on(table.emailId),
710
+ ]);
698
711
  export const emailIndexPending = sqliteTable("email_index_pending", {
699
712
  id: integer("id").notNull(),
700
713
  indexableType: text("indexable_type").notNull(),