@wirecat/cli-core 0.358.0 → 0.360.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 CHANGED
@@ -5,6 +5,36 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.360.0 — 19.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Adapter test authors can shorten a connection's close wait and re-key pauses.** The run environment takes
15
+ `closeWaitMs` (how long a connection's close waits on its saves and passive listens, 5 s by default) and
16
+ `rekeyPacing` (`{ lockMs, pauseMs }` for chat re-keys, 50 ms and 25 ms by default). Commands, the shared services and
17
+ the MCP server's tool calls pass them on; a command `serve` runs for a client through its relay keeps the
18
+ defaults. A test through `run()` no longer pays the real waits. Only code
19
+ that builds the environment can set them; no flag, variable or MCP call does
20
+ ([ADAPTERS.md](docs/dev/ADAPTERS.md#running-the-contract-cases)).
21
+
22
+ ## 0.359.0 — 19.10.2026
23
+
24
+ ### Fixed
25
+
26
+ - **The first `serve` after an upgrade no longer locks other commands out of the store while it re-keys chats.**
27
+ A pushed batch's `rekeys` were saved one transaction each, back to back, so another command waited for the write
28
+ 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
29
+ through the new `store.chats.rekeyChats` in passes of one transaction, each holding the lock for about 50 ms with
30
+ a pause after it. Each re-key keeps its own savepoint, privacy merge and account scope, and a refused one still
31
+ warns and leaves the others applied. Once the store is about to close (`Saving.closing`, which `connected`
32
+ aborts) or refuses a whole pass (closed, busy past its timeout), every re-key still queued, in any batch, is given
33
+ up with one warning: "N chat re-keys not applied yet; the next serve applies them", and one
34
+ `rekeys_not_applied` event whose new `counts` field (`WarningEvent.counts`) holds N. Before, a messenger that sends
35
+ each re-key as its own batch (wa) got one `store_not_written` per chat left. `ChatsStore` gains `rekeyChats`, so a
36
+ hand-written `ChatsStore` must add it.
37
+
8
38
  ## 0.358.0 — 19.10.2026
9
39
 
10
40
  ### Added
@@ -35,6 +35,13 @@ export interface BaseEnvironment {
35
35
  recognizer?: OpenRecognizer;
36
36
  /** How long `store restore` and `store reset` wait on another connection's write, 1 s unless a test hands one in. */
37
37
  storeLockWaitMs?: number;
38
+ /** How long a connection's close waits on its saves and passive listens, 5 s unless a test hands one in. */
39
+ closeWaitMs?: number;
40
+ /** How long one pass of chat re-keys holds the store's write lock and the pause after it; tests hand in zeros. */
41
+ rekeyPacing?: {
42
+ lockMs: number;
43
+ pauseMs: number;
44
+ };
38
45
  /** Where raw bytes go when stdout is not this process's own — an MCP session relayed by serve. */
39
46
  stdout?: NodeJS.WritableStream;
40
47
  /** Where a prompt and what is typed at it go, in place of the process's stderr; tests hand one in. */
@@ -8,7 +8,7 @@ import { type PermissionKey } from "../../sends/permissions.js";
8
8
  import { type ServiceMessenger, type Services } from "../../services/index.js";
9
9
  import type { OpenRecognizer } from "../../speech/transcribe.js";
10
10
  import { type AccountKey, type DeletionScope, type MessageStore } from "../../store/store.js";
11
- import { type BaseContext } from "../context.js";
11
+ import { type BaseContext, type BaseEnvironment } from "../context.js";
12
12
  import { type PermissionKeyOf } from "../permission-keys.js";
13
13
  import type { GlobalFlags, ResolveOptions, Settings } from "../settings.js";
14
14
  /**
@@ -225,6 +225,8 @@ export interface MessengerContext extends BaseContext {
225
225
  name?: string;
226
226
  }) => Promise<T>;
227
227
  }
228
+ /** The waits a test shortens through its environment; a run without them keeps the defaults. */
229
+ type ConnectionWaits = Pick<BaseEnvironment, "closeWaitMs" | "rekeyPacing">;
228
230
  /**
229
231
  * A connection as every shared reader sees it: its account remembered, each call a run event, what
230
232
  * the reads answer saved to the store. `close` closes the connection and the store it opened. One
@@ -234,7 +236,7 @@ export interface MessengerContext extends BaseContext {
234
236
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
235
237
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
236
238
  */
237
- export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching }?: Pick<ConnectOptions, "listen">) => {
239
+ export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching, closeWaitMs, rekeyPacing, }?: Pick<ConnectOptions, "listen"> & ConnectionWaits) => {
238
240
  adapter: MessengerAdapter;
239
241
  close: () => Promise<void>;
240
242
  };
@@ -253,3 +255,4 @@ export declare const connectedFor: (command: Command, messenger: Pick<Messenger,
253
255
  */
254
256
  export declare const refuseLocalWrite: (context: MessengerContext, command: string, permission: PermissionKey) => void;
255
257
  export declare const messengerContext: (command: Command, messenger: Messenger, resolving?: Pick<ResolveOptions, "needsProfile">) => MessengerContext;
258
+ export {};
@@ -118,7 +118,7 @@ const settled = async (pending, ms) => {
118
118
  * never fails the command; one that fails once the connection is `closing` is the close, not news. It still listens
119
119
  * while the connection closes, so what arrives then is saved too.
120
120
  */
121
- const listened = ({ run, operation, what }, events, warn) => {
121
+ const listened = ({ run, operation, what }, events, warn, waitMs) => {
122
122
  const abort = new AbortController();
123
123
  let closing = false;
124
124
  const ended = Promise.resolve()
@@ -137,7 +137,7 @@ const listened = ({ run, operation, what }, events, warn) => {
137
137
  },
138
138
  stop: () => {
139
139
  abort.abort();
140
- return settled(new Set([ended]), SAVES_WAIT_MS);
140
+ return settled(new Set([ended]), waitMs);
141
141
  },
142
142
  };
143
143
  };
@@ -171,6 +171,10 @@ const rekeyRecipients = async (app, profile, env, warn, from, to) => {
171
171
  warn(`the recipient list still names ${from}: ${error instanceof Error ? error.message : String(error)}`);
172
172
  }
173
173
  };
174
+ const waitsOf = (command) => {
175
+ const { closeWaitMs, rekeyPacing } = environmentOf(command);
176
+ return { ...(closeWaitMs === undefined ? {} : { closeWaitMs }), ...(rekeyPacing ? { rekeyPacing } : {}) };
177
+ };
174
178
  /**
175
179
  * A connection as every shared reader sees it: its account remembered, each call a run event, what
176
180
  * the reads answer saved to the store. `close` closes the connection and the store it opened. One
@@ -180,7 +184,7 @@ const rekeyRecipients = async (app, profile, env, warn, from, to) => {
180
184
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
181
185
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
182
186
  */
183
- export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false } = {}) => {
187
+ export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
184
188
  const self = connection.self();
185
189
  if (self !== null)
186
190
  rememberAccount(app, settings.profile, self, env);
@@ -194,6 +198,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
194
198
  const adapter = observed(flooded(connection, memory, { name: name ?? app.command, warn: renderer.warn, pacer }), events);
195
199
  const pending = new Set();
196
200
  const announcing = new Set();
201
+ const closing = new AbortController();
197
202
  const saving = self === null
198
203
  ? adapter
199
204
  : stored(adapter, {
@@ -204,13 +209,15 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
204
209
  events,
205
210
  pending,
206
211
  announcing,
212
+ closing: closing.signal,
207
213
  ...(deletedWithoutChat ? { deletedWithoutChat } : {}),
214
+ ...(rekeyPacing ? { rekeyPacing } : {}),
208
215
  rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
209
216
  recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
210
217
  ...(history === "store" ? { namesFromStore: savedChatId ? { savedChatId } : {} } : {}),
211
218
  });
212
219
  const passives = history === "store" && self !== null && !watching
213
- ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn))
220
+ ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn, closeWaitMs))
214
221
  : [];
215
222
  return {
216
223
  adapter: saving,
@@ -229,7 +236,8 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
229
236
  events({ event: "warning", code: "store_not_written", operation });
230
237
  renderer.warn(`not saved to the local store: the messenger was still handing on ${what} at exit`);
231
238
  });
232
- await settled(new Set([...pending, ...announcing]), SAVES_WAIT_MS);
239
+ await settled(new Set([...pending, ...announcing]), closeWaitMs);
240
+ closing.abort();
233
241
  if (pending.size > 0) {
234
242
  events({ event: "warning", code: "store_not_written", operation: "close" });
235
243
  renderer.warn("not saved to the local store: the last messages were still being written at exit");
@@ -262,7 +270,10 @@ export const connectedFor = async (command, messenger, base, options) => {
262
270
  }
263
271
  return {
264
272
  connection,
265
- ...connected(connection, messenger, base, options.events, store, options.listen ? { listen: true } : {}),
273
+ ...connected(connection, messenger, base, options.events, store, {
274
+ ...waitsOf(command),
275
+ ...(options.listen ? { listen: true } : {}),
276
+ }),
266
277
  };
267
278
  };
268
279
  const unrecorded = (command, messenger, profile, offline) => {
@@ -407,7 +418,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
407
418
  throw new CliError("validation_error", "the read connection has already been released");
408
419
  }
409
420
  base.track(connection);
410
- held = connected(connection, messenger, base, events);
421
+ held = connected(connection, messenger, base, events, undefined, waitsOf(command));
411
422
  }
412
423
  return held.adapter;
413
424
  },
@@ -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
- events({ event: "warning", code: "store_not_written", operation });
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
- // Each re-key apart: one refused leaves the chat split, never the batch's history unsaved.
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
- for (const { from, to } of batch.rekeys ?? [])
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
- void saving.finally(() => pending?.delete(saving));
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
@@ -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 {
@@ -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
- return [`${MARK.warning} ${(event.operation ?? "").padEnd(16)} ${event.code}`, event.detail]
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
  }
@@ -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.
@@ -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)
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.358.0",
3
+ "version": "0.360.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",