@wirecat/cli-core 0.549.0 → 0.550.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/CHANGELOG.md +47 -22
- package/dist/errors/reasons.js +1 -0
- package/dist/mail/partial.d.ts +9 -3
- package/dist/mail/partial.js +35 -10
- package/dist/mail/source.d.ts +7 -1
- package/dist/mail/sync.js +1 -1
- package/dist/store/sqlite/email-locations.d.ts +22 -1
- package/dist/store/sqlite/email-locations.js +29 -2
- package/dist/store/sqlite/emails.d.ts +3 -7
- package/dist/store/sqlite/emails.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.550.0 — 02.11.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **cli-core registers `mail.search-refused`.** A CLI that listed it in its own reasons gets "listed twice" from
|
|
15
|
+
`defineReasons` and must drop its entry; mail does.
|
|
16
|
+
- **`MailStore` has `markPartial(accountId, externalId, mark)`, and `partialEmails` answers `stuck` and leaves out an
|
|
17
|
+
email marked `partial.stuck`.** A store of its own must add both. `PartialOutcome` (`syncMail`'s `partial`) gains
|
|
18
|
+
`stuck`, and `partial` is answered whenever any of `completed`, `left` or `stuck` is not 0.
|
|
19
|
+
- **cli-core now needs `@wirecat/cli-base` 0.74.0**, which holds the `--compact` switch's name. A CLI that pins
|
|
20
|
+
cli-base bumps it to 0.74.0 together with cli-core.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **A partial email the server no longer gives at its stored place is given up on instead of read whole on every
|
|
25
|
+
sync.** `completePartial` reads it at its newest stored location under that location's UIDVALIDITY (the new optional
|
|
26
|
+
`Quick.validity`, which a source checks before reading), counts each failed read in `metadata.partial.tries`, and
|
|
27
|
+
after 3, or at once when the place holds another message, marks it `partial.stuck`: it stays readable as it is, is
|
|
28
|
+
counted in `partial.stuck`, and no longer takes one of the 100 places a run reads, so it never holds back newer ones.
|
|
29
|
+
An email with no stored location is given up on at once.
|
|
30
|
+
- **A server search never stores a partial copy over an email already stored whole.** A sync in another process could
|
|
31
|
+
save the email between the search's check and its write; the write then dropped every attachment. The store now
|
|
32
|
+
keeps a live whole email as it is when a copy marked `partial` is saved over it.
|
|
33
|
+
- **`--brief` now cuts a messenger search hit.** It only knew mail's camelCase hits, so `search messages --brief` in
|
|
34
|
+
tg, max and wa printed every field; it now keeps `ref`, `chat_title`, `timestamp`, `sender_name`, `excerpt` and
|
|
35
|
+
`score`. The default and `--full` answers are unchanged. Commands that print through `outputFor` now honour
|
|
36
|
+
`--brief` and `--full` too, and refuse them together as the others do.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`--compact`: one line per item for an agent, from the same answer `--json` prints.** `messages list <chat>
|
|
41
|
+
--compact` prints `#<id> · 10-01 09:12 · Анна · <text>` under one header line that names the shared ref prefix
|
|
42
|
+
(`ref: msg:max/500/-1001/<id>`), what every row has in common (the chat's title), the date span and the zone. Rows
|
|
43
|
+
from several chats keep their whole refs. Text stays on one line with control and direction characters made
|
|
44
|
+
visible, and is cut at 400 characters with `…(+N)`; `--full` keeps it whole. The paging, `missing`, the next steps
|
|
45
|
+
and an incomplete answer follow as `# more:`, `# missing:`, `# next:` and `# complete: false` lines on stdout, and
|
|
46
|
+
each command line in them carries `--compact` again. On 1 000 messages of one chat it is about 48 % of the JSON
|
|
47
|
+
bytes; the texts alone are 40 %. `--compact` with `--json`, `--json=…`, `--jsonl` or `--brief` fails with
|
|
48
|
+
`validation_error` (`output.conflict`); it never prompts, and exits with the code JSON would. MCP keeps JSON.
|
|
49
|
+
- **Every command that answers through `renderAnswer` gets it.** A command draws with its own row
|
|
50
|
+
(`answerEntity(cmd, name, schema, { row })`), its app's (`rows: { name: shape }` in the program definition,
|
|
51
|
+
which also draws an app entity listed inside one thing), the shared one in `SHARED_ROWS` (`message`,
|
|
52
|
+
`message.summary`, `message.hit`, `chat`), or the generic row: key, ref or id · title, name or path · state,
|
|
53
|
+
status or due date · text. A record or a list is never printed as a column's value. `RowShape` is data, and `compactText(body, shape,
|
|
54
|
+
options)` is pure, for an app that prints its own answers.
|
|
55
|
+
- What a command prints without `renderAnswer` stays JSON under `--compact`.
|
|
56
|
+
|
|
10
57
|
## 0.549.0 — 02.11.2026
|
|
11
58
|
|
|
12
59
|
### Added
|
|
@@ -293,22 +340,6 @@ shape of this file.
|
|
|
293
340
|
**`knowledge.decideSuggestedLink(id, decision)`** an older unconfirmed link, each in one transaction: the link and
|
|
294
341
|
the decision land together or not at all. A decision's `reason` is kept: on a rejected proposal as its result, on a
|
|
295
342
|
fact in `metadata.decision`, on an evidence findings receipt.
|
|
296
|
-
- **`--compact`: one line per item for an agent, from the same answer `--json` prints.** `messages list <chat>
|
|
297
|
-
--compact` prints `#<id> · 10-01 09:12 · Анна · <text>` under one header line that names the shared ref prefix
|
|
298
|
-
(`ref: msg:max/500/-1001/<id>`), what every row has in common (the chat's title), the date span and the zone. Rows
|
|
299
|
-
from several chats keep their whole refs. Text stays on one line with control and direction characters made
|
|
300
|
-
visible, and is cut at 400 characters with `…(+N)`; `--full` keeps it whole. The paging, `missing`, the next steps
|
|
301
|
-
and an incomplete answer follow as `# more:`, `# missing:`, `# next:` and `# complete: false` lines on stdout, and
|
|
302
|
-
each command line in them carries `--compact` again. On 1 000 messages of one chat it is about 48 % of the JSON
|
|
303
|
-
bytes; the texts alone are 40 %. `--compact` with `--json`, `--json=…`, `--jsonl` or `--brief` fails with
|
|
304
|
-
`validation_error` (`output.conflict`); it never prompts, and exits with the code JSON would. MCP keeps JSON.
|
|
305
|
-
- **Every command that answers through `renderAnswer` gets it.** A command draws with its own row
|
|
306
|
-
(`answerEntity(cmd, name, schema, { row })`), its app's (`rows: { name: shape }` in the program definition,
|
|
307
|
-
which also draws an app entity listed inside one thing), the shared one in `SHARED_ROWS` (`message`,
|
|
308
|
-
`message.summary`, `message.hit`, `chat`), or the generic row: key, ref or id · title, name or path · state,
|
|
309
|
-
status or due date · text. A record or a list is never printed as a column's value. `RowShape` is data, and `compactText(body, shape,
|
|
310
|
-
options)` is pure, for an app that prints its own answers.
|
|
311
|
-
- What a command prints without `renderAnswer` stays JSON under `--compact`.
|
|
312
343
|
|
|
313
344
|
### Changed — may break callers
|
|
314
345
|
|
|
@@ -351,8 +382,6 @@ shape of this file.
|
|
|
351
382
|
the agent's bot and reason otherwise.
|
|
352
383
|
An agent's confirmation of a relationship is recorded in the link's provenance, and of a fact or proposal as its
|
|
353
384
|
decider.
|
|
354
|
-
- **cli-core now needs `@wirecat/cli-base` 0.74.0**, which holds the `--compact` switch's name. A CLI that pins
|
|
355
|
-
cli-base bumps it to 0.74.0 together with cli-core.
|
|
356
385
|
|
|
357
386
|
### Fixed
|
|
358
387
|
|
|
@@ -366,10 +395,6 @@ shape of this file.
|
|
|
366
395
|
`starred:`.
|
|
367
396
|
- **An inclusive start keeps a message sent exactly at it.** `messages list 'date>=2026-10-01'` skipped a message
|
|
368
397
|
sent at the first millisecond of the day, and `date<="…"` one sent at that moment.
|
|
369
|
-
- **`--brief` now cuts a messenger search hit.** It only knew mail's camelCase hits, so `search messages --brief` in
|
|
370
|
-
tg, max and wa printed every field; it now keeps `ref`, `chat_title`, `timestamp`, `sender_name`, `excerpt` and
|
|
371
|
-
`score`. The default and `--full` answers are unchanged. Commands that print through `outputFor` now honour
|
|
372
|
-
`--brief` and `--full` too, and refuse them together as the others do.
|
|
373
398
|
|
|
374
399
|
### Security
|
|
375
400
|
|
package/dist/errors/reasons.js
CHANGED
|
@@ -50,6 +50,7 @@ const OTHERS = [
|
|
|
50
50
|
validation("extraction.text-too-long", "the text is too long for memory extraction"),
|
|
51
51
|
validation("file.locked", "another process holds the file's lock"),
|
|
52
52
|
["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
|
|
53
|
+
["mail.search-refused", "provider_error", "the mail server refused to search a folder"],
|
|
53
54
|
["mirror.partial", "provider_error", "some mirrored files were not synced"],
|
|
54
55
|
["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
|
|
55
56
|
validation("push.too-many-files", "the folder holds more files than one push takes"),
|
package/dist/mail/partial.d.ts
CHANGED
|
@@ -6,16 +6,22 @@ import type { MailSource } from "./source.js";
|
|
|
6
6
|
export declare const PARTIAL = "partial";
|
|
7
7
|
/** How many partial emails one sync reads whole at most; the rest wait for the next run. */
|
|
8
8
|
export declare const PARTIAL_PER_RUN = 100;
|
|
9
|
+
/** Failed reads after which a partial email is given up on (`partial.stuck`) and stays as it is. */
|
|
10
|
+
export declare const PARTIAL_TRIES = 3;
|
|
9
11
|
export interface PartialOutcome {
|
|
10
12
|
/** Read whole this run, the mark cleared. */
|
|
11
13
|
completed: number;
|
|
12
|
-
/** Still partial after this run. */
|
|
14
|
+
/** Still partial after this run, tried again by the next. */
|
|
13
15
|
left: number;
|
|
16
|
+
/** Given up on: the server no longer gives this email at its stored place. They stay readable as they are. */
|
|
17
|
+
stuck: number;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
20
|
* Reads whole the emails a server search stored from their first bytes, so the archive holds their attachments too.
|
|
17
|
-
* Sync never reads stored mail again, so without this step a partial email would stay partial.
|
|
18
|
-
*
|
|
21
|
+
* Sync never reads stored mail again, so without this step a partial email would stay partial. A source with `find`
|
|
22
|
+
* finds them by id; elsewhere they are read at their newest stored location under its UIDVALIDITY, trusted only when
|
|
23
|
+
* `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
|
|
24
|
+
* at once when the place holds another message, the email is left partial for good.
|
|
19
25
|
*/
|
|
20
26
|
export declare const completePartial: ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }: {
|
|
21
27
|
store: MessageStore;
|
package/dist/mail/partial.js
CHANGED
|
@@ -6,15 +6,29 @@ import { parseMail } from "./parse.js";
|
|
|
6
6
|
export const PARTIAL = "partial";
|
|
7
7
|
/** How many partial emails one sync reads whole at most; the rest wait for the next run. */
|
|
8
8
|
export const PARTIAL_PER_RUN = 100;
|
|
9
|
+
/** Failed reads after which a partial email is given up on (`partial.stuck`) and stays as it is. */
|
|
10
|
+
export const PARTIAL_TRIES = 3;
|
|
9
11
|
/**
|
|
10
12
|
* Reads whole the emails a server search stored from their first bytes, so the archive holds their attachments too.
|
|
11
|
-
* Sync never reads stored mail again, so without this step a partial email would stay partial.
|
|
12
|
-
*
|
|
13
|
+
* Sync never reads stored mail again, so without this step a partial email would stay partial. A source with `find`
|
|
14
|
+
* finds them by id; elsewhere they are read at their newest stored location under its UIDVALIDITY, trusted only when
|
|
15
|
+
* `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
|
|
16
|
+
* at once when the place holds another message, the email is left partial for good.
|
|
13
17
|
*/
|
|
14
18
|
export const completePartial = async ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }) => {
|
|
15
|
-
const { emails, total } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
|
|
19
|
+
const { emails, total, stuck } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
|
|
16
20
|
if (emails.length === 0)
|
|
17
|
-
return { completed: 0, left: 0 };
|
|
21
|
+
return { completed: 0, left: 0, stuck };
|
|
22
|
+
let given = 0;
|
|
23
|
+
// A failed read is counted, so an email the server no longer gives is not read whole on every sync forever.
|
|
24
|
+
const failed = async (email, forGood = false) => {
|
|
25
|
+
const mark = (email.metadata?.[PARTIAL] ?? {});
|
|
26
|
+
const tries = Number(mark.tries ?? 0) + 1;
|
|
27
|
+
const gaveUp = forGood || tries >= PARTIAL_TRIES;
|
|
28
|
+
await store.mail.markPartial(accountId, email.externalId, { ...mark, tries, ...(gaveUp ? { stuck: true } : {}) });
|
|
29
|
+
if (gaveUp)
|
|
30
|
+
given++;
|
|
31
|
+
};
|
|
18
32
|
const byId = new Map(emails.map((email) => [email.externalId, email]));
|
|
19
33
|
const items = new Map();
|
|
20
34
|
if (source.find) {
|
|
@@ -23,21 +37,27 @@ export const completePartial = async ({ store, source, accountId, address, folde
|
|
|
23
37
|
if (left.length === 0)
|
|
24
38
|
break;
|
|
25
39
|
if (take?.())
|
|
26
|
-
return { completed: 0, left: total };
|
|
40
|
+
return { completed: 0, left: total, stuck };
|
|
27
41
|
for (const found of await source.find(left, folder)) {
|
|
28
42
|
const email = byId.get(found.messageId);
|
|
29
43
|
if (email && !items.has(email))
|
|
30
44
|
items.set(email, found);
|
|
31
45
|
}
|
|
32
46
|
}
|
|
47
|
+
for (const email of emails)
|
|
48
|
+
if (!items.has(email))
|
|
49
|
+
await failed(email);
|
|
33
50
|
}
|
|
34
51
|
else if (source.identify)
|
|
35
52
|
for (const email of emails) {
|
|
36
53
|
const place = email.locations[0];
|
|
37
|
-
if (place)
|
|
54
|
+
if (!place)
|
|
55
|
+
await failed(email, true);
|
|
56
|
+
else
|
|
38
57
|
items.set(email, {
|
|
39
58
|
uid: String(place.uid),
|
|
40
59
|
folder: place.folder,
|
|
60
|
+
validity: place.uidvalidity,
|
|
41
61
|
receivedAt: new Date(email.receivedAt ?? 0).toISOString(),
|
|
42
62
|
});
|
|
43
63
|
}
|
|
@@ -58,11 +78,15 @@ export const completePartial = async ({ store, source, accountId, address, folde
|
|
|
58
78
|
const attachments = [];
|
|
59
79
|
for (const [index, [email, item]] of chunk.entries()) {
|
|
60
80
|
const raw = raws[index];
|
|
61
|
-
if (!(raw instanceof Uint8Array))
|
|
81
|
+
if (!(raw instanceof Uint8Array)) {
|
|
82
|
+
await failed(email);
|
|
62
83
|
continue;
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
}
|
|
85
|
+
// Only the stored id proves the bytes at a stored UID are this email; another one there will not change back.
|
|
86
|
+
if (!source.find && source.identify?.(raw, item).messageId !== email.externalId) {
|
|
87
|
+
await failed(email, true);
|
|
65
88
|
continue;
|
|
89
|
+
}
|
|
66
90
|
try {
|
|
67
91
|
const mail = await parseMail(raw, maxBodyChars);
|
|
68
92
|
const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(signal ? { signal } : {}) }, attachments);
|
|
@@ -87,8 +111,9 @@ export const completePartial = async ({ store, source, accountId, address, folde
|
|
|
87
111
|
catch {
|
|
88
112
|
if (signal?.aborted)
|
|
89
113
|
break;
|
|
114
|
+
await failed(email);
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
|
-
return { completed, left: total - completed };
|
|
118
|
+
return { completed, left: total - completed - given, stuck: stuck + given };
|
|
94
119
|
};
|
package/dist/mail/source.d.ts
CHANGED
|
@@ -24,7 +24,13 @@ export interface Listed {
|
|
|
24
24
|
headers?: MailHeaders;
|
|
25
25
|
}
|
|
26
26
|
/** A message as `quick` lists it: everything but the ids, which a source without a cheap one reads from the raw bytes. */
|
|
27
|
-
export type Quick = Omit<Listed, "messageId" | "threadId"
|
|
27
|
+
export type Quick = Omit<Listed, "messageId" | "threadId"> & {
|
|
28
|
+
/**
|
|
29
|
+
* The folder's UIDVALIDITY the UID was stored under. `fetchRaw` reads nothing for it when the folder's differs, and
|
|
30
|
+
* `identify` works the ids out under it.
|
|
31
|
+
*/
|
|
32
|
+
validity?: string;
|
|
33
|
+
};
|
|
28
34
|
export interface Searched {
|
|
29
35
|
/** Absent when the server does not say; then no UID is trusted across runs. */
|
|
30
36
|
validity?: string;
|
package/dist/mail/sync.js
CHANGED
|
@@ -54,7 +54,7 @@ export const syncMail = async (source, store, options) => {
|
|
|
54
54
|
...(options.budget ? { take: () => options.budget?.take() ?? null } : {}),
|
|
55
55
|
...(options.signal ? { signal: options.signal } : {}),
|
|
56
56
|
});
|
|
57
|
-
return partial.completed || partial.left ? { ...result, partial } : result;
|
|
57
|
+
return partial.completed || partial.left || partial.stuck ? { ...result, partial } : result;
|
|
58
58
|
};
|
|
59
59
|
const syncRounds = async (source, store, options) => {
|
|
60
60
|
const { budget, index } = options;
|
|
@@ -23,7 +23,8 @@ export declare const locate: (database: CacheDatabase, accountId: number, emailI
|
|
|
23
23
|
export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
|
|
24
24
|
/**
|
|
25
25
|
* An email stored from its first bytes only (`metadata.partial`): a sync reads it whole and clears the mark.
|
|
26
|
-
* `partialEmails` lists an account's live ones newest first, with how many there are in all
|
|
26
|
+
* `partialEmails` lists an account's live ones newest first, with how many there are in all; one given up on
|
|
27
|
+
* (`partial.stuck`) is left out and only counted, so it never holds back newer ones. Its newest location comes first.
|
|
27
28
|
*/
|
|
28
29
|
export interface PartialEmail {
|
|
29
30
|
externalId: string;
|
|
@@ -35,6 +36,26 @@ export interface PartialEmail {
|
|
|
35
36
|
export declare const partialEmails: (database: CacheDatabase, accountId: number, limit: number) => {
|
|
36
37
|
emails: PartialEmail[];
|
|
37
38
|
total: number;
|
|
39
|
+
stuck: number;
|
|
38
40
|
};
|
|
41
|
+
/** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
|
|
42
|
+
export declare const markPartial: (database: CacheDatabase, accountId: number, externalId: string, mark: Metadata) => {
|
|
43
|
+
changes: number;
|
|
44
|
+
};
|
|
45
|
+
export interface PartialMailStore {
|
|
46
|
+
partialEmails(accountId: number, limit: number): Promise<{
|
|
47
|
+
emails: PartialEmail[];
|
|
48
|
+
total: number;
|
|
49
|
+
stuck: number;
|
|
50
|
+
}>;
|
|
51
|
+
/** Records a failed read of a partial email in its `metadata.partial`; a whole email is left alone. */
|
|
52
|
+
markPartial(accountId: number, externalId: string, mark: Metadata): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export declare const partialMail: (database: CacheDatabase) => PartialMailStore;
|
|
55
|
+
/**
|
|
56
|
+
* A live email stored whole, which a partial copy must not replace: a server search checks what is held before its
|
|
57
|
+
* slow read, and a sync in another process may store the email whole in between.
|
|
58
|
+
*/
|
|
59
|
+
export declare const storedWhole: (database: CacheDatabase, emailId: number) => boolean;
|
|
39
60
|
export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
|
|
40
61
|
export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fromJson } from "./events.js";
|
|
2
|
+
import { write } from "./transaction.js";
|
|
2
3
|
export const locate = (database, accountId, emailId, where) => database
|
|
3
4
|
.prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
|
|
4
5
|
SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
|
|
@@ -26,14 +27,22 @@ export const partialEmails = (database, accountId, limit) => {
|
|
|
26
27
|
const rows = database
|
|
27
28
|
.prepare(`SELECT e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.metadata,
|
|
28
29
|
count(*) OVER () AS total,
|
|
29
|
-
(SELECT json_group_array(json_object('folder',
|
|
30
|
-
FROM
|
|
30
|
+
(SELECT json_group_array(json_object('folder', folder, 'uidvalidity', uidvalidity, 'uid', uid))
|
|
31
|
+
FROM (SELECT b.external_id AS folder, l.uidvalidity, l.uid
|
|
32
|
+
FROM email_locations l JOIN mailboxes b ON b.id = l.mailbox_id
|
|
33
|
+
WHERE l.email_id = e.id ORDER BY l.rowid DESC)) AS places
|
|
31
34
|
FROM emails e JOIN email_threads t ON t.id = e.email_thread_id
|
|
32
35
|
WHERE e.account_id = ? AND e.deleted_at IS NULL AND json_extract(e.metadata, '$.partial') IS NOT NULL
|
|
36
|
+
AND json_extract(e.metadata, '$.partial.stuck') IS NULL
|
|
33
37
|
ORDER BY at DESC, e.id DESC LIMIT ?`)
|
|
34
38
|
.all(accountId, limit);
|
|
39
|
+
const stuck = database
|
|
40
|
+
.prepare(`SELECT count(*) AS n FROM emails WHERE account_id = ? AND deleted_at IS NULL
|
|
41
|
+
AND json_extract(metadata, '$.partial.stuck') IS NOT NULL`)
|
|
42
|
+
.get(accountId);
|
|
35
43
|
return {
|
|
36
44
|
total: rows.length ? Number(rows[0]?.total) : 0,
|
|
45
|
+
stuck: Number(stuck?.n ?? 0),
|
|
37
46
|
emails: rows.map((row) => ({
|
|
38
47
|
externalId: String(row.external_id),
|
|
39
48
|
threadExternalId: String(row.thread),
|
|
@@ -47,6 +56,24 @@ export const partialEmails = (database, accountId, limit) => {
|
|
|
47
56
|
})),
|
|
48
57
|
};
|
|
49
58
|
};
|
|
59
|
+
/** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
|
|
60
|
+
export const markPartial = (database, accountId, externalId, mark) => database
|
|
61
|
+
.prepare(`UPDATE emails SET metadata = json_set(metadata, '$.partial', json(?)) WHERE account_id = ? AND external_id = ?
|
|
62
|
+
AND json_extract(metadata, '$.partial') IS NOT NULL`)
|
|
63
|
+
.run(JSON.stringify(mark), accountId, externalId);
|
|
64
|
+
export const partialMail = (database) => ({
|
|
65
|
+
partialEmails: async (accountId, limit) => partialEmails(database, accountId, limit),
|
|
66
|
+
markPartial: async (accountId, externalId, mark) => {
|
|
67
|
+
write(database, () => markPartial(database, accountId, externalId, mark));
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* A live email stored whole, which a partial copy must not replace: a server search checks what is held before its
|
|
72
|
+
* slow read, and a sync in another process may store the email whole in between.
|
|
73
|
+
*/
|
|
74
|
+
export const storedWhole = (database, emailId) => database
|
|
75
|
+
.prepare("SELECT 1 FROM emails WHERE id = ? AND deleted_at IS NULL AND json_extract(metadata, '$.partial') IS NULL")
|
|
76
|
+
.get(emailId) !== undefined;
|
|
50
77
|
export const setLocations = (database, accountId, folder, uidvalidity, found) => {
|
|
51
78
|
database
|
|
52
79
|
.prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
|
|
@@ -3,13 +3,13 @@ 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, type
|
|
6
|
+
import { type EmailLocationInput, type FolderEmail, type PartialMailStore } from "./email-locations.js";
|
|
7
7
|
import { type MailRulesStore } from "./mail-rules.js";
|
|
8
8
|
import { type AttachmentInput } from "./meetings.js";
|
|
9
9
|
import type { StoreContext } from "./open.js";
|
|
10
10
|
import { type SyncStateSave } from "./sync.js";
|
|
11
11
|
import { type ThreadFilter } from "./thread-columns.js";
|
|
12
|
-
export type { EmailLocationInput, FolderEmail, PartialEmail };
|
|
12
|
+
export type { EmailLocationInput, FolderEmail, PartialEmail } from "./email-locations.js";
|
|
13
13
|
export interface EmailAddress {
|
|
14
14
|
address: string;
|
|
15
15
|
name: string | null;
|
|
@@ -205,7 +205,7 @@ export interface SenderScore {
|
|
|
205
205
|
demoted?: boolean;
|
|
206
206
|
}
|
|
207
207
|
/** Mail in tables of its own: threads, emails, recipients and mailboxes, keyed by account and Message-ID. */
|
|
208
|
-
export interface MailStore {
|
|
208
|
+
export interface MailStore extends PartialMailStore {
|
|
209
209
|
saveThread(input: ThreadSave): Promise<ThreadDetails>;
|
|
210
210
|
/** Several threads and sync state in one transaction: a slice of mail commits with its resume point, or not at all. */
|
|
211
211
|
saveThreads(inputs: ThreadSave[], states?: SyncStateSave[]): Promise<ThreadDetails[]>;
|
|
@@ -217,10 +217,6 @@ export interface MailStore {
|
|
|
217
217
|
emails(filter?: EmailFilter): Promise<Email[]>;
|
|
218
218
|
/** Each matching email's Message-ID and mailboxes, without text or people: cheap over a whole mailbox. */
|
|
219
219
|
emailRefs(filter?: Omit<EmailFilter, "limit" | "offset">): Promise<EmailRef[]>;
|
|
220
|
-
partialEmails(accountId: number, limit: number): Promise<{
|
|
221
|
-
emails: PartialEmail[];
|
|
222
|
-
total: number;
|
|
223
|
-
}>;
|
|
224
220
|
/** Each matching email's sender, recipients, list, size, flag and mailboxes, oldest first; no text. */
|
|
225
221
|
emailFacts(filter?: Omit<EmailFilter, "limit" | "offset">): Promise<EmailFacts[]>;
|
|
226
222
|
/**
|
|
@@ -2,7 +2,7 @@ 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 { folderEmails, forgetLocations, locate,
|
|
5
|
+
import { folderEmails, forgetLocations, locate, partialMail, setLocations, storedWhole, } from "./email-locations.js";
|
|
6
6
|
import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
|
|
7
7
|
import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
|
|
8
8
|
import { folded } from "./folded.js";
|
|
@@ -204,6 +204,8 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
|
|
|
204
204
|
const found = database
|
|
205
205
|
.prepare("SELECT id, email_thread_id FROM emails WHERE account_id = ? AND external_id = ?")
|
|
206
206
|
.get(accountId, mail.externalId);
|
|
207
|
+
if (found && mail.metadata?.partial !== undefined && storedWhole(database, Number(found.id)))
|
|
208
|
+
return null;
|
|
207
209
|
let emailId;
|
|
208
210
|
if (found) {
|
|
209
211
|
emailId = Number(found.id);
|
|
@@ -469,7 +471,7 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
|
|
|
469
471
|
mailboxes: JSON.parse(String(row.boxes)),
|
|
470
472
|
}));
|
|
471
473
|
},
|
|
472
|
-
|
|
474
|
+
...partialMail(database),
|
|
473
475
|
async emailFacts(filter = {}) {
|
|
474
476
|
const [where, params] = emailWhere(filter);
|
|
475
477
|
return database
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.550.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|