@wirecat/cli-core 0.357.0 → 0.359.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 +26 -0
- package/dist/cli/meeting-communications.d.ts +1 -1
- package/dist/cli/messenger/context.js +3 -0
- package/dist/cli/messenger/stored.d.ts +8 -1
- package/dist/cli/messenger/stored.js +69 -12
- package/dist/context/evidence-response.js +13 -3
- package/dist/context/evidence.d.ts +2 -2
- package/dist/context/evidence.js +1 -1
- package/dist/runs/events.d.ts +2 -0
- package/dist/runs/events.js +2 -1
- package/dist/store/chats.d.ts +9 -1
- package/dist/store/chats.js +9 -1
- package/dist/store/exports.d.ts +1 -1
- package/dist/store/sqlite/chat-rekey.d.ts +22 -0
- package/dist/store/sqlite/chat-rekey.js +25 -0
- package/dist/store/sqlite/context-evidence.js +4 -1
- package/dist/store/sqlite/context-meeting-source.d.ts +6 -0
- package/dist/store/sqlite/context-meeting-source.js +97 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.359.0 — 19.10.2026
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **The first `serve` after an upgrade no longer locks other commands out of the store while it re-keys chats.**
|
|
15
|
+
A pushed batch's `rekeys` were saved one transaction each, back to back, so another command waited for the write
|
|
16
|
+
lock as long as the re-keys ran: up to 3.4 s for 5,000 re-keys on a synthetic 10,000-chat store. They now go
|
|
17
|
+
through the new `store.chats.rekeyChats` in passes of one transaction, each holding the lock for about 50 ms with
|
|
18
|
+
a pause after it. Each re-key keeps its own savepoint, privacy merge and account scope, and a refused one still
|
|
19
|
+
warns and leaves the others applied. Once the store is about to close (`Saving.closing`, which `connected`
|
|
20
|
+
aborts) or refuses a whole pass (closed, busy past its timeout), every re-key still queued, in any batch, is given
|
|
21
|
+
up with one warning: "N chat re-keys not applied yet; the next serve applies them", and one
|
|
22
|
+
`rekeys_not_applied` event whose new `counts` field (`WarningEvent.counts`) holds N. Before, a messenger that sends
|
|
23
|
+
each re-key as its own batch (wa) got one `store_not_written` per chat left. `ChatsStore` gains `rekeyChats`, so a
|
|
24
|
+
hand-written `ChatsStore` must add it.
|
|
25
|
+
|
|
26
|
+
## 0.358.0 — 19.10.2026
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Meeting transcripts in bounded context reads:** `ContextSource.kind` accepts `meeting`; the native
|
|
31
|
+
reader returns current transcript cue references with `timeKind: "meeting-start"`. Account/time scope,
|
|
32
|
+
cursor validation, read-only snapshots and pre-hydration byte limits apply. Deleted/superseded and
|
|
33
|
+
queued cues are excluded; missing indexes and unknown archive coverage remain explicit. Hosts must
|
|
34
|
+
opt in to meeting sources; the reader does not fetch recordings, fill indexes or infer project links.
|
|
35
|
+
|
|
10
36
|
## 0.357.0 — 19.10.2026
|
|
11
37
|
|
|
12
38
|
### Added
|
|
@@ -37,7 +37,7 @@ export declare const mapPreparationCommunications: ({ items, ...section }: Meeti
|
|
|
37
37
|
title: string | null;
|
|
38
38
|
url: string | null;
|
|
39
39
|
text: string;
|
|
40
|
-
timeKind: "sent" | "source-modified" | "stored-update";
|
|
40
|
+
timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
|
|
41
41
|
revision: number | null;
|
|
42
42
|
selectionId: string;
|
|
43
43
|
textTruncated: boolean;
|
|
@@ -194,6 +194,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
|
|
|
194
194
|
const adapter = observed(flooded(connection, memory, { name: name ?? app.command, warn: renderer.warn, pacer }), events);
|
|
195
195
|
const pending = new Set();
|
|
196
196
|
const announcing = new Set();
|
|
197
|
+
const closing = new AbortController();
|
|
197
198
|
const saving = self === null
|
|
198
199
|
? adapter
|
|
199
200
|
: stored(adapter, {
|
|
@@ -204,6 +205,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
|
|
|
204
205
|
events,
|
|
205
206
|
pending,
|
|
206
207
|
announcing,
|
|
208
|
+
closing: closing.signal,
|
|
207
209
|
...(deletedWithoutChat ? { deletedWithoutChat } : {}),
|
|
208
210
|
rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
|
|
209
211
|
recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
|
|
@@ -230,6 +232,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
|
|
|
230
232
|
renderer.warn(`not saved to the local store: the messenger was still handing on ${what} at exit`);
|
|
231
233
|
});
|
|
232
234
|
await settled(new Set([...pending, ...announcing]), SAVES_WAIT_MS);
|
|
235
|
+
closing.abort();
|
|
233
236
|
if (pending.size > 0) {
|
|
234
237
|
events({ event: "warning", code: "store_not_written", operation: "close" });
|
|
235
238
|
renderer.warn("not saved to the local store: the last messages were still being written at exit");
|
|
@@ -21,6 +21,13 @@ export interface Saving {
|
|
|
21
21
|
};
|
|
22
22
|
/** After a re-key is saved: what outside the store spells the chat id follows it. */
|
|
23
23
|
rekeyed?: (from: Id, to: Id) => void | Promise<void>;
|
|
24
|
+
/** How long one pass of re-keys may hold the write lock, and the pause after it. */
|
|
25
|
+
rekeyPacing?: {
|
|
26
|
+
lockMs: number;
|
|
27
|
+
pauseMs: number;
|
|
28
|
+
};
|
|
29
|
+
/** Aborted just before the store closes: the re-keys still queued are given up, with one warning for them all. */
|
|
30
|
+
closing?: AbortSignal;
|
|
24
31
|
/**
|
|
25
32
|
* Chat and partner ids this account's recipient list names, told to `storedChats` apart from the stored ones: an
|
|
26
33
|
* entry saved before any chat was stored under its id would otherwise never be re-keyed, and the send guard would
|
|
@@ -41,4 +48,4 @@ export interface Saving {
|
|
|
41
48
|
* would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
|
|
42
49
|
* known to be whole, and `saveMembers` replaces the list.
|
|
43
50
|
*/
|
|
44
|
-
export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
|
|
51
|
+
export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
|
|
@@ -3,6 +3,7 @@ import { capability, throughWrapper } from "../../ports/messenger.js";
|
|
|
3
3
|
import { isId, pickPerson } from "../../resolve.js";
|
|
4
4
|
import { codeOf } from "../../sends/guarded.js";
|
|
5
5
|
import { chatIdIn } from "../../services/message-resolution.js";
|
|
6
|
+
const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
|
|
6
7
|
/**
|
|
7
8
|
* The adapter, with what each read answers written to the store. **A failed write never fails the
|
|
8
9
|
* read** — the answer is already here, and it is what was asked for.
|
|
@@ -11,9 +12,15 @@ import { chatIdIn } from "../../services/message-resolution.js";
|
|
|
11
12
|
* would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
|
|
12
13
|
* known to be whole, and `saveMembers` replaces the list.
|
|
13
14
|
*/
|
|
14
|
-
export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }) => {
|
|
15
|
+
export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, }) => {
|
|
15
16
|
let warned = false;
|
|
16
17
|
let recorded = profile === undefined;
|
|
18
|
+
const notWritten = (operation, error) => {
|
|
19
|
+
events({ event: "warning", code: "store_not_written", operation });
|
|
20
|
+
if (!warned)
|
|
21
|
+
warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
warned = true;
|
|
23
|
+
};
|
|
17
24
|
const attempt = async (operation, write) => {
|
|
18
25
|
try {
|
|
19
26
|
const opened = await store();
|
|
@@ -28,10 +35,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
|
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
catch (error) {
|
|
31
|
-
|
|
32
|
-
if (!warned)
|
|
33
|
-
warn(`not saved to the local store: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
-
warned = true;
|
|
38
|
+
notWritten(operation, error);
|
|
35
39
|
}
|
|
36
40
|
};
|
|
37
41
|
const save = (operation, writing) => {
|
|
@@ -72,6 +76,54 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
|
|
|
72
76
|
return undefined;
|
|
73
77
|
}
|
|
74
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* The first serve after an upgrade re-keys every phone-form chat at once. Back-to-back transactions keep the write
|
|
81
|
+
* lock so busy that another command waits past `busy_timeout`, so the re-keys go in passes with a pause between.
|
|
82
|
+
*/
|
|
83
|
+
let rekeysQueued = 0;
|
|
84
|
+
let rekeyRound = 0;
|
|
85
|
+
const rekeysOnly = new Set();
|
|
86
|
+
// wa sends each re-key as its own batch, so a closed store would otherwise refuse, and warn for, every chat left.
|
|
87
|
+
const giveUpRekeys = () => {
|
|
88
|
+
rekeyRound += 1;
|
|
89
|
+
const count = rekeysQueued;
|
|
90
|
+
rekeysQueued = 0;
|
|
91
|
+
for (const saving of rekeysOnly)
|
|
92
|
+
pending?.delete(saving);
|
|
93
|
+
if (count === 0)
|
|
94
|
+
return;
|
|
95
|
+
events({ event: "warning", code: "rekeys_not_applied", operation: "history.rekey", counts: { rekeys: count } });
|
|
96
|
+
warn(`${count} chat re-key${count === 1 ? "" : "s"} not applied yet; the next serve applies them`);
|
|
97
|
+
};
|
|
98
|
+
closing?.addEventListener("abort", giveUpRekeys, { once: true });
|
|
99
|
+
const saveRekeys = async (pairs, round) => {
|
|
100
|
+
let rest = pairs;
|
|
101
|
+
while (rest.length > 0 && round === rekeyRound) {
|
|
102
|
+
let pass;
|
|
103
|
+
try {
|
|
104
|
+
pass = await (await store())?.chats.rekeyChats(account, rest, rekeyPacing.lockMs);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Closed at exit or busy past its timeout, the store would refuse every pass after this one too.
|
|
108
|
+
if (round === rekeyRound)
|
|
109
|
+
giveUpRekeys();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (round !== rekeyRound)
|
|
113
|
+
return;
|
|
114
|
+
const taken = pass?.taken ?? rest.length;
|
|
115
|
+
rekeysQueued -= taken;
|
|
116
|
+
for (const { from, to } of pass?.applied ?? [])
|
|
117
|
+
await Promise.resolve()
|
|
118
|
+
.then(() => rekeyed?.(from, to))
|
|
119
|
+
.catch((error) => notWritten("history.rekey", error));
|
|
120
|
+
for (const { error } of pass?.refused ?? [])
|
|
121
|
+
notWritten("history.rekey", error);
|
|
122
|
+
rest = rest.slice(taken);
|
|
123
|
+
if (rest.length > 0)
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, rekeyPacing.pauseMs));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
75
127
|
const saveBatches = (operation, onBatch) => {
|
|
76
128
|
// In order: a batch's messages may belong to chats only an earlier batch named.
|
|
77
129
|
let previous = Promise.resolve();
|
|
@@ -87,18 +139,22 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
|
|
|
87
139
|
if (chat.title)
|
|
88
140
|
titles.set(chat.id, chat.title);
|
|
89
141
|
}
|
|
90
|
-
|
|
142
|
+
const rekeys = batch.rekeys ?? [];
|
|
143
|
+
const round = rekeyRound;
|
|
144
|
+
rekeysQueued += rekeys.length;
|
|
145
|
+
// A refused re-key leaves its chat split, never the others or the batch's history unsaved.
|
|
91
146
|
const saving = previous.then(async () => {
|
|
92
|
-
|
|
93
|
-
await attempt("history.rekey", async (opened) => {
|
|
94
|
-
await opened.chats.rekeyChat(account, from, to);
|
|
95
|
-
await rekeyed?.(from, to);
|
|
96
|
-
});
|
|
147
|
+
await saveRekeys(rekeys, round);
|
|
97
148
|
await attempt(operation, (opened) => fill(opened, account, batch));
|
|
98
149
|
});
|
|
99
150
|
previous = saving;
|
|
100
151
|
pending?.add(saving);
|
|
101
|
-
|
|
152
|
+
if (onlyRekeys(batch))
|
|
153
|
+
rekeysOnly.add(saving);
|
|
154
|
+
void saving.finally(() => {
|
|
155
|
+
pending?.delete(saving);
|
|
156
|
+
rekeysOnly.delete(saving);
|
|
157
|
+
});
|
|
102
158
|
onBatch(batch);
|
|
103
159
|
};
|
|
104
160
|
};
|
|
@@ -436,6 +492,7 @@ const titled = (event, titles) => {
|
|
|
436
492
|
const title = titles.get(event.chatId);
|
|
437
493
|
return title ? { ...event, chatTitle: title } : event;
|
|
438
494
|
};
|
|
495
|
+
const onlyRekeys = (batch) => Object.entries(batch).every(([part, items]) => (part === "rekeys") === (items?.length ?? 0) > 0);
|
|
439
496
|
/**
|
|
440
497
|
* A pushed batch as the store keeps it. No `markChatsLeft`: a push names some chats, never all of them.
|
|
441
498
|
* No `seenAt`: a pushed snapshot may be older than a deletion this store already holds. A chat's votes and stars go
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { formatLocator, parseLocator } from "../domain/locator.js";
|
|
3
|
+
import { formatMeetingReference, parseMeetingReference } from "../meetings/index.js";
|
|
3
4
|
import { contextEvidenceRequest, contextSource, } from "./evidence.js";
|
|
4
5
|
export const contextSourceKey = (source) => JSON.stringify([source.kind, source.accountId, source.provider, source.account]);
|
|
5
6
|
export const validateContextEvidencePage = (page, request) => {
|
|
@@ -49,9 +50,9 @@ export const validateContextEvidencePage = (page, request) => {
|
|
|
49
50
|
fail();
|
|
50
51
|
if (item.revision !== null && (!Number.isSafeInteger(item.revision) || item.revision < 1))
|
|
51
52
|
fail();
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
(source.kind === "
|
|
53
|
+
if (source.kind === "document"
|
|
54
|
+
? !["source-modified", "stored-update"].includes(item.timeKind)
|
|
55
|
+
: item.timeKind !== (source.kind === "meeting" ? "meeting-start" : "sent"))
|
|
55
56
|
fail();
|
|
56
57
|
const text = [item.text, item.title, item.url, item.provider, item.account, item.timeKind];
|
|
57
58
|
if (text.some((value) => value !== null && typeof value !== "string"))
|
|
@@ -61,6 +62,15 @@ export const validateContextEvidencePage = (page, request) => {
|
|
|
61
62
|
if (!Number.isSafeInteger(id) || id < 1 || item.ref !== `document:${id}`)
|
|
62
63
|
fail();
|
|
63
64
|
}
|
|
65
|
+
else if (source.kind === "meeting") {
|
|
66
|
+
const reference = parseMeetingReference(item.ref);
|
|
67
|
+
if (reference.accountId !== source.accountId ||
|
|
68
|
+
reference.transcriptId === undefined ||
|
|
69
|
+
reference.cuePosition === undefined ||
|
|
70
|
+
formatMeetingReference(reference) !== item.ref)
|
|
71
|
+
fail();
|
|
72
|
+
text.push(item.ref);
|
|
73
|
+
}
|
|
64
74
|
else {
|
|
65
75
|
const locator = parseLocator(item.ref);
|
|
66
76
|
if (locator.provider !== source.provider ||
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const CONTEXT_SOURCE_KINDS: readonly ["message", "mail", "document"];
|
|
1
|
+
export declare const CONTEXT_SOURCE_KINDS: readonly ["message", "mail", "document", "meeting"];
|
|
2
2
|
export interface ContextSource {
|
|
3
3
|
kind: (typeof CONTEXT_SOURCE_KINDS)[number];
|
|
4
4
|
accountId: number;
|
|
@@ -27,7 +27,7 @@ export interface ContextEvidenceItem {
|
|
|
27
27
|
url: string | null;
|
|
28
28
|
text: string;
|
|
29
29
|
at: number;
|
|
30
|
-
timeKind: "sent" | "source-modified" | "stored-update";
|
|
30
|
+
timeKind: "sent" | "source-modified" | "stored-update" | "meeting-start";
|
|
31
31
|
updatedAt: number;
|
|
32
32
|
revision: number | null;
|
|
33
33
|
}
|
package/dist/context/evidence.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { CliError } from "@wirecat/cli-base";
|
|
3
|
-
export const CONTEXT_SOURCE_KINDS = ["message", "mail", "document"];
|
|
3
|
+
export const CONTEXT_SOURCE_KINDS = ["message", "mail", "document", "meeting"];
|
|
4
4
|
export const contextCursorScope = (request) => createHash("sha256")
|
|
5
5
|
.update(JSON.stringify([
|
|
6
6
|
"context-evidence-v1",
|
package/dist/runs/events.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface WarningEvent {
|
|
|
34
34
|
operation?: string;
|
|
35
35
|
/** Field paths and types for an answer of an unexpected shape — built without the values. */
|
|
36
36
|
detail?: string;
|
|
37
|
+
/** How many things the warning is about, per field: `{ rekeys: 199 }`. */
|
|
38
|
+
counts?: Record<string, number>;
|
|
37
39
|
}
|
|
38
40
|
/** A read answered locally, without asking the messenger, and why. */
|
|
39
41
|
export interface CacheEvent {
|
package/dist/runs/events.js
CHANGED
|
@@ -10,7 +10,8 @@ export const providerErrorKey = (value) => typeof value === "string" && /^[A-Za-
|
|
|
10
10
|
*/
|
|
11
11
|
export const renderEvent = (event) => {
|
|
12
12
|
if (event.event === "warning") {
|
|
13
|
-
|
|
13
|
+
const counts = Object.entries(event.counts ?? {}).map(([field, count]) => `${count} ${field}`);
|
|
14
|
+
return [`${MARK.warning} ${(event.operation ?? "").padEnd(16)} ${event.code}`, ...counts, event.detail]
|
|
14
15
|
.filter(Boolean)
|
|
15
16
|
.join(" ");
|
|
16
17
|
}
|
package/dist/store/chats.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChannelTagMatch } from "../domain/channel-tags.js";
|
|
2
2
|
import type { Chat, ChatKind, ChatUpdate, Id, Page } from "../domain/models.js";
|
|
3
|
-
import { type ChatRekey } from "./sqlite/chat-rekey.js";
|
|
3
|
+
import { type ChatRekey, type ChatRekeys } from "./sqlite/chat-rekey.js";
|
|
4
4
|
import type { ChatMetadata, LeftChat } from "./sqlite/chats.js";
|
|
5
5
|
import type { ChatCompleteness } from "./sqlite/completeness.js";
|
|
6
6
|
import type { StoreContext } from "./sqlite/open.js";
|
|
@@ -39,6 +39,14 @@ export interface ChatsStore {
|
|
|
39
39
|
* `to`. Replaying it changes nothing.
|
|
40
40
|
*/
|
|
41
41
|
rekeyChat(key: AccountKey, from: Id, to: Id): Promise<ChatRekey>;
|
|
42
|
+
/**
|
|
43
|
+
* `rekeyChat` for a run of pairs, in order, in one transaction that ends once it has held the write lock for
|
|
44
|
+
* `lockMs`: `taken` says how many it got to. A pair refused is in `refused`, and the others still apply.
|
|
45
|
+
*/
|
|
46
|
+
rekeyChats(key: AccountKey, pairs: readonly {
|
|
47
|
+
from: Id;
|
|
48
|
+
to: Id;
|
|
49
|
+
}[], lockMs: number): Promise<ChatRekeys>;
|
|
42
50
|
/**
|
|
43
51
|
* The chats marked left and the messages they hold, each named in `left`; with `clear`, deletes them and all under
|
|
44
52
|
* them — `left` is then what was deleted.
|
package/dist/store/chats.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { keysOver } from "./keys.js";
|
|
3
|
-
import { rekeyChat } from "./sqlite/chat-rekey.js";
|
|
3
|
+
import { rekeyChat, rekeyChats } from "./sqlite/chat-rekey.js";
|
|
4
4
|
import * as chatQueries from "./sqlite/chats.js";
|
|
5
5
|
import * as completeness from "./sqlite/completeness.js";
|
|
6
6
|
import * as reads from "./sqlite/reads.js";
|
|
@@ -49,6 +49,14 @@ export const chatsStoreOver = (context) => {
|
|
|
49
49
|
return { outcome: "unchanged", messages: 0, duplicates: 0 };
|
|
50
50
|
return write(database, () => rekeyChat(context, key, accountKey, from, to));
|
|
51
51
|
},
|
|
52
|
+
rekeyChats: async (key, pairs, lockMs) => {
|
|
53
|
+
const accountKey = findAccountPk(key);
|
|
54
|
+
if (accountKey === undefined) {
|
|
55
|
+
const rekey = { outcome: "unchanged", messages: 0, duplicates: 0 };
|
|
56
|
+
return { taken: pairs.length, applied: pairs.map((pair) => ({ ...pair, rekey })), refused: [] };
|
|
57
|
+
}
|
|
58
|
+
return write(database, () => rekeyChats(context, key, accountKey, pairs, lockMs));
|
|
59
|
+
},
|
|
52
60
|
leftChats: async (key, { clear = false } = {}) => {
|
|
53
61
|
const accountKey = findAccountPk(key);
|
|
54
62
|
if (accountKey === undefined)
|
package/dist/store/exports.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { accountGroupsOf } from "./sqlite/account-groups.js";
|
|
|
16
16
|
export type { AdminStoreRequest, AdminStoreResult } from "./sqlite/admin-statistics.js";
|
|
17
17
|
export { type AttachmentTextEntry, type AttachmentView, attachmentColumns, type FileAttachment, resetAttachmentWords, type TextOrigin, } from "./sqlite/attachment-texts.js";
|
|
18
18
|
export { pendingNormalization } from "./sqlite/backfill.js";
|
|
19
|
-
export type { ChatRekey } from "./sqlite/chat-rekey.js";
|
|
19
|
+
export type { ChatRekey, ChatRekeys } from "./sqlite/chat-rekey.js";
|
|
20
20
|
export { CHAT_COLUMNS, titlePart } from "./sqlite/chats.js";
|
|
21
21
|
export { CHAT_LIST_KEY, type ChatCompleteness, fetchedKey, historyStartKey } from "./sqlite/completeness.js";
|
|
22
22
|
export { CONVERSATION_COLUMNS } from "./sqlite/conversations.js";
|
|
@@ -8,8 +8,30 @@ export interface ChatRekey {
|
|
|
8
8
|
/** Messages both chats held under one id; the kept chat's copy stays. */
|
|
9
9
|
duplicates: number;
|
|
10
10
|
}
|
|
11
|
+
export interface ChatRekeys {
|
|
12
|
+
/** How many of the pairs this pass got to, from the first: the rest wait for another pass. */
|
|
13
|
+
taken: number;
|
|
14
|
+
applied: {
|
|
15
|
+
from: Id;
|
|
16
|
+
to: Id;
|
|
17
|
+
rekey: ChatRekey;
|
|
18
|
+
}[];
|
|
19
|
+
refused: {
|
|
20
|
+
from: Id;
|
|
21
|
+
to: Id;
|
|
22
|
+
error: unknown;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
11
25
|
/**
|
|
12
26
|
* A chat the messenger now names by another id: renamed in place, or merged into the row already stored under the
|
|
13
27
|
* new id's twin. Replaying it, or naming a chat the store does not hold, changes nothing. Runs inside `write`.
|
|
14
28
|
*/
|
|
15
29
|
export declare const rekeyChat: (context: StoreContext, key: AccountKey, accountKey: number, from: Id, to: Id) => ChatRekey;
|
|
30
|
+
/**
|
|
31
|
+
* Re-keys in order, each in its own savepoint so a refused one leaves the others applied, and stops once `lockMs` has
|
|
32
|
+
* passed: the caller lets the other processes at the write lock before the next pass. Runs inside `write`.
|
|
33
|
+
*/
|
|
34
|
+
export declare const rekeyChats: (context: StoreContext, key: AccountKey, accountKey: number, pairs: readonly {
|
|
35
|
+
from: Id;
|
|
36
|
+
to: Id;
|
|
37
|
+
}[], lockMs: number) => ChatRekeys;
|
|
@@ -4,6 +4,7 @@ import { purgeChats, stricterPrivacy } from "./chats.js";
|
|
|
4
4
|
import { pointerPairs } from "./entity-types.js";
|
|
5
5
|
import { tombstone } from "./messages.js";
|
|
6
6
|
import { targetKey } from "./notes.js";
|
|
7
|
+
import { write } from "./transaction.js";
|
|
7
8
|
const chatRow = (context, accountKey, chatId) => context.database.prepare("SELECT * FROM chats WHERE account_id = ? AND external_id = ?").get(accountKey, chatId);
|
|
8
9
|
/**
|
|
9
10
|
* Re-points every `<x>_type = type` pointer from one row to another. A pointer the kept row already has (a unique
|
|
@@ -185,3 +186,27 @@ export const rekeyChat = (context, key, accountKey, from, to) => {
|
|
|
185
186
|
renameText(context, key, accountKey, from, to);
|
|
186
187
|
return { outcome: twin ? "merged" : "renamed", ...moved };
|
|
187
188
|
};
|
|
189
|
+
/**
|
|
190
|
+
* Re-keys in order, each in its own savepoint so a refused one leaves the others applied, and stops once `lockMs` has
|
|
191
|
+
* passed: the caller lets the other processes at the write lock before the next pass. Runs inside `write`.
|
|
192
|
+
*/
|
|
193
|
+
export const rekeyChats = (context, key, accountKey, pairs, lockMs) => {
|
|
194
|
+
const done = { taken: 0, applied: [], refused: [] };
|
|
195
|
+
const started = performance.now();
|
|
196
|
+
for (const { from, to } of pairs) {
|
|
197
|
+
if (done.taken > 0 && performance.now() - started >= lockMs)
|
|
198
|
+
break;
|
|
199
|
+
done.taken += 1;
|
|
200
|
+
try {
|
|
201
|
+
done.applied.push({
|
|
202
|
+
from,
|
|
203
|
+
to,
|
|
204
|
+
rekey: write(context.database, () => rekeyChat(context, key, accountKey, from, to)),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
done.refused.push({ from, to, error });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return done;
|
|
212
|
+
};
|
|
@@ -3,6 +3,7 @@ import { contextCursorScope, contextEvidenceRequest, } from "../../context/evide
|
|
|
3
3
|
import { formatLocator } from "../../domain/locator.js";
|
|
4
4
|
import { NORMALIZER_VERSION, normalize } from "../normalize.js";
|
|
5
5
|
import { contextDocumentTime, contextDocumentTimeKind, contextDocumentUrl } from "./context-document-source.js";
|
|
6
|
+
import { readMeetingContextEvidence } from "./context-meeting-source.js";
|
|
6
7
|
import { drainEmailIndex } from "./email-index.js";
|
|
7
8
|
import { drainNoteIndex } from "./note-index.js";
|
|
8
9
|
import { read } from "./transaction.js";
|
|
@@ -52,10 +53,12 @@ export const contextEvidenceOver = ({ database }) => ({
|
|
|
52
53
|
async read(original) {
|
|
53
54
|
const request = contextEvidenceRequest(original);
|
|
54
55
|
const { source, words, after, before, limit, maxReadBytes, cursor, signal } = request;
|
|
56
|
+
if (source.kind === "meeting")
|
|
57
|
+
return readMeetingContextEvidence(database, request);
|
|
58
|
+
const corpus = corpora[source.kind];
|
|
55
59
|
const scope = contextCursorScope({ source, query: request.query, after, before });
|
|
56
60
|
signal?.throwIfAborted();
|
|
57
61
|
return read(database, () => {
|
|
58
|
-
const corpus = corpora[source.kind];
|
|
59
62
|
const coverage = {
|
|
60
63
|
available: false,
|
|
61
64
|
index: "unknown",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ContextEvidencePage, type contextEvidenceRequest } from "../../context/evidence.js";
|
|
2
|
+
import type { CacheDatabase } from "../driver.js";
|
|
3
|
+
type Request = ReturnType<typeof contextEvidenceRequest>;
|
|
4
|
+
/** Transcript cues keep their retained revision reference; a meeting title alone is not a transcript match. */
|
|
5
|
+
export declare const readMeetingContextEvidence: (database: CacheDatabase, request: Request) => ContextEvidencePage;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { contextCursorScope } from "../../context/evidence.js";
|
|
3
|
+
import { normalize } from "../normalize.js";
|
|
4
|
+
import { read } from "./transaction.js";
|
|
5
|
+
/** Transcript cues keep their retained revision reference; a meeting title alone is not a transcript match. */
|
|
6
|
+
export const readMeetingContextEvidence = (database, request) => {
|
|
7
|
+
const { source, words, after, before, limit, maxReadBytes, cursor, signal } = request;
|
|
8
|
+
signal?.throwIfAborted();
|
|
9
|
+
return read(database, () => {
|
|
10
|
+
const coverage = {
|
|
11
|
+
available: false,
|
|
12
|
+
index: "unknown",
|
|
13
|
+
hasMore: false,
|
|
14
|
+
hasUndated: false,
|
|
15
|
+
completeness: "unknown",
|
|
16
|
+
matching: "literal-words",
|
|
17
|
+
consistency: "snapshot-per-page",
|
|
18
|
+
};
|
|
19
|
+
const empty = () => ({ source, items: [], next: null, readBytes: 0, coverage });
|
|
20
|
+
const scope = [source.accountId, source.provider, source.account];
|
|
21
|
+
if (!database.prepare("SELECT 1 FROM accounts WHERE id=? AND provider=? AND external_id=?").get(...scope))
|
|
22
|
+
return empty();
|
|
23
|
+
coverage.available = true;
|
|
24
|
+
const base = `FROM meeting_transcript_rows r
|
|
25
|
+
JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id
|
|
26
|
+
JOIN meetings m ON m.id=t.meeting_id
|
|
27
|
+
JOIN accounts a ON a.id=m.account_id
|
|
28
|
+
WHERE m.account_id=? AND a.provider=? AND a.external_id=?
|
|
29
|
+
AND m.deleted_at IS NULL AND t.deleted_at IS NULL AND t.superseded_at IS NULL`;
|
|
30
|
+
coverage.hasUndated = Boolean(database.prepare(`SELECT 1 ${base} AND m.started_at IS NULL LIMIT 1`).get(...scope));
|
|
31
|
+
if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='meeting_words'").get()) {
|
|
32
|
+
coverage.index = "missing";
|
|
33
|
+
return empty();
|
|
34
|
+
}
|
|
35
|
+
coverage.index = database
|
|
36
|
+
.prepare(`SELECT 1 ${base} AND m.started_at>=? AND m.started_at<?
|
|
37
|
+
AND EXISTS(SELECT 1 FROM meeting_index_pending p
|
|
38
|
+
WHERE p.indexable_type='meeting_transcript_row' AND p.id=r.id) LIMIT 1`)
|
|
39
|
+
.get(...scope, after, before)
|
|
40
|
+
? "pending"
|
|
41
|
+
: "ready";
|
|
42
|
+
const reference = "'meeting:'||m.account_id||'/'||m.id||'/'||t.id||'/'||r.position";
|
|
43
|
+
const selection = `SELECT r.id,m.title,r.text,a.provider,a.external_id AS account,
|
|
44
|
+
m.started_at AS at,max(m.updated_at,t.updated_at,r.created_at) AS updated_at,
|
|
45
|
+
${reference} AS ref ${base} AND m.started_at>=? AND m.started_at<?
|
|
46
|
+
AND (r.id*4+1) IN (SELECT rowid FROM meeting_words WHERE meeting_words MATCH ?)
|
|
47
|
+
AND NOT EXISTS(SELECT 1 FROM meeting_index_pending p
|
|
48
|
+
WHERE p.indexable_type='meeting_transcript_row' AND p.id=r.id)
|
|
49
|
+
${cursor ? "AND (m.started_at<? OR (m.started_at=? AND r.id<?))" : ""}
|
|
50
|
+
ORDER BY m.started_at DESC,r.id DESC LIMIT ?`;
|
|
51
|
+
const params = [
|
|
52
|
+
...scope,
|
|
53
|
+
after,
|
|
54
|
+
before,
|
|
55
|
+
words.map((word) => `normalized_text:"${normalize(word)}"`).join(" AND "),
|
|
56
|
+
];
|
|
57
|
+
if (cursor)
|
|
58
|
+
params.push(cursor.at, cursor.at, cursor.id);
|
|
59
|
+
params.push(limit + 1);
|
|
60
|
+
const probe = database
|
|
61
|
+
.prepare(`SELECT coalesce(sum(length(CAST(coalesce(title,'') AS BLOB))+
|
|
62
|
+
length(CAST(text AS BLOB))+length(CAST(provider AS BLOB))+
|
|
63
|
+
length(CAST(account AS BLOB))+length(CAST(ref AS BLOB))+length('meeting-start')),0) AS bytes FROM (${selection})`)
|
|
64
|
+
.get(...params);
|
|
65
|
+
const readBytes = Number(probe?.bytes);
|
|
66
|
+
if (!Number.isSafeInteger(readBytes) || readBytes < 0 || readBytes > maxReadBytes)
|
|
67
|
+
throw new CliError("validation_error", "meeting context evidence exceeds maxReadBytes before hydration", {
|
|
68
|
+
reason: "context.read-budget",
|
|
69
|
+
});
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
const rows = database.prepare(selection).all(...params);
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
coverage.hasMore = rows.length > limit;
|
|
74
|
+
const selected = rows.slice(0, limit);
|
|
75
|
+
const last = selected.at(-1);
|
|
76
|
+
return {
|
|
77
|
+
source,
|
|
78
|
+
readBytes,
|
|
79
|
+
coverage,
|
|
80
|
+
next: coverage.hasMore && last
|
|
81
|
+
? { at: Number(last.at), id: Number(last.id), scope: contextCursorScope(request) }
|
|
82
|
+
: null,
|
|
83
|
+
items: selected.map((row) => ({
|
|
84
|
+
ref: String(row.ref),
|
|
85
|
+
provider: String(row.provider),
|
|
86
|
+
account: String(row.account),
|
|
87
|
+
title: row.title === null ? null : String(row.title),
|
|
88
|
+
url: null,
|
|
89
|
+
text: String(row.text),
|
|
90
|
+
at: Number(row.at),
|
|
91
|
+
timeKind: "meeting-start",
|
|
92
|
+
updatedAt: Number(row.updated_at),
|
|
93
|
+
revision: null,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.359.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",
|