@wirecat/cli-core 0.345.0 → 0.346.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
@@ -7,6 +7,26 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.346.0 — 18.10.2026
11
+
12
+ ### Added
13
+
14
+ - **The contract kit checks `listen`** when the adapter has it: on a signal already aborted it resolves at once
15
+ and does not reject, and otherwise it keeps listening until the signal aborts, then resolves. `OPTIONAL_METHODS`
16
+ lists `listen`, and `fakeAdapter(seed, { listen: true })` has it. The cases cannot see whether `listen` connects.
17
+
18
+ ### Changed — may break callers
19
+
20
+ - **Saving a chat the store already holds merges its `providerMetadata` key by key, and `parentChatId` keeps or
21
+ clears the same way.** A metadata key the new chat leaves out keeps its stored value; a key set to `null` is
22
+ removed, and metadata with no key left is stored as none. `parentChatId` left out keeps the stored parent, `null`
23
+ clears it (it could never be cleared before), and `Chat.parentChatId` is now `Id | null`. Before, the new
24
+ metadata replaced the stored one whole, so a push that left a key out erased it: a WhatsApp community lost
25
+ `community: true` on a push that did not say, and max-cli's login erased each dialog's `partnerId`. **Adapters
26
+ must send `null` for a key that is gone, or a flag that turned false; an absent key now means unknown.** An
27
+ adapter that leaves such a key out keeps the old value in the store, and a stale `username` still answers to
28
+ `@name` until the chat is saved with `username: null`. Chats the store already holds are not changed.
29
+
10
30
  ## 0.345.0 — 18.10.2026
11
31
 
12
32
  ### Added
@@ -30,7 +30,7 @@ export const chatAnswer = (account, chat) => defined({
30
30
  muted: chat.muted,
31
31
  archived: chat.archived,
32
32
  membership_state: chat.membershipState,
33
- parent_chat_id: chat.parentChatId,
33
+ parent_chat_id: chat.parentChatId ?? undefined,
34
34
  scope: chat.scope,
35
35
  provider_metadata: chat.providerMetadata,
36
36
  description: chat.description,
@@ -19,7 +19,8 @@ export type ChatKind = "dialog" | "group" | "channel" | "saved" | "unknown";
19
19
  /** Where the owner stands in a chat. Kept apart from whether its messages are searchable: a chat left keeps them. */
20
20
  export type MembershipState = "joined" | "left" | "public" | "imported" | "archived" | "external";
21
21
  export interface Chat {
22
- parentChatId?: Id;
22
+ /** Saving a chat: absent keeps the stored parent, `null` clears it. */
23
+ parentChatId?: Id | null;
23
24
  scope?: "personal" | "work";
24
25
  id: Id;
25
26
  title: string | null;
@@ -36,6 +37,10 @@ export interface Chat {
36
37
  unreadMentions?: number;
37
38
  /** Absent where the messenger does not say. */
38
39
  membershipState?: MembershipState;
40
+ /**
41
+ * Saving a chat merges this into the stored one key by key: a key left out keeps its stored value, a key set to
42
+ * `null` is removed. So a key that is gone, or a flag that turned false, is sent as `null`.
43
+ */
39
44
  providerMetadata?: ProviderMetadata;
40
45
  }
41
46
  /** A group's switches; `null` where the messenger does not say, or has no such switch. */
@@ -16,13 +16,13 @@ export interface ContractOptions {
16
16
  ids?: IdMaker;
17
17
  /** As the messenger's `fetching.orderBy`: whether `history`'s `before` is a message id or an ISO time. */
18
18
  orderBy?: "id" | "time";
19
- /** How long `watch` may take to say it listens, and to end once aborted. */
19
+ /** How long `watch` may take to say it listens, and `watch`, `listen` and `feed` to end once aborted. */
20
20
  waitMs?: number;
21
21
  /** As the messenger's `history`: a `"server"` messenger's adapter must have every `ServerReads` method. */
22
22
  history?: "server" | "store";
23
23
  }
24
24
  /** Every method of the optional groups, which `capability` hands over or refuses. */
25
- export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
25
+ export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "listen", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
26
26
  /**
27
27
  * The port's promises as cases any test runner can run: each connects through `connect`, checks
28
28
  * with `node:assert/strict` and closes the adapter. None needs a live service.
@@ -28,6 +28,7 @@ export const OPTIONAL_METHODS = [
28
28
  "closePoll",
29
29
  "createPoll",
30
30
  "watch",
31
+ "listen",
31
32
  "feed",
32
33
  "download",
33
34
  "transcribe",
@@ -82,6 +83,17 @@ const within = async (work, failure, ms) => {
82
83
  clearTimeout(timer);
83
84
  }
84
85
  };
86
+ const settledSoon = async (work) => {
87
+ let settled = false;
88
+ work.then(() => {
89
+ settled = true;
90
+ }, () => {
91
+ settled = true;
92
+ });
93
+ for (let turn = 0; turn < 5; turn += 1)
94
+ await new Promise((resolve) => setImmediate(resolve));
95
+ return settled;
96
+ };
85
97
  const idsOf = (messages) => messages.map((message) => message.id);
86
98
  /** Every `id`, `chatId` and `senderId` anywhere in `value`, so a number among them is caught. */
87
99
  const everyId = (value, found = []) => {
@@ -394,6 +406,30 @@ export const contractCases = ({ connect, ids = wordIds, orderBy = "id", waitMs =
394
406
  },
395
407
  "`connect` opens a connection that listens, as `{ listen: true }` does",
396
408
  ],
409
+ [
410
+ "listen on a signal already aborted resolves at once and does not reject",
411
+ async (adapter) => {
412
+ const skipped = lacking(adapter, "listen");
413
+ if (skipped)
414
+ return skipped;
415
+ const controller = new AbortController();
416
+ controller.abort();
417
+ await within(Promise.resolve(adapter.listen?.(() => { }, controller.signal)), "listen did not resolve on a signal already aborted", waitMs);
418
+ },
419
+ ],
420
+ [
421
+ "listen keeps listening until the signal aborts, then resolves",
422
+ async (adapter) => {
423
+ const skipped = lacking(adapter, "listen");
424
+ if (skipped)
425
+ return skipped;
426
+ const controller = new AbortController();
427
+ const listening = Promise.resolve(adapter.listen?.(() => { }, controller.signal));
428
+ assert.equal(await settledSoon(listening), false, "listen ended before the signal aborted");
429
+ controller.abort();
430
+ await within(listening, "listen did not end when the signal aborted", waitMs);
431
+ },
432
+ ],
397
433
  [
398
434
  "feed pushes only the seed's chats and messages, with ids as strings, and ends when the signal aborts",
399
435
  async (adapter, seed) => {
@@ -1,7 +1,8 @@
1
- import type { AccountEditing, AccountTools, ChatFolders, ChatReading, ContactBook, GroupAdmin, GroupModeration, LiveUpdates, MessageEditing, MessageMedia, MessagePins, MessagePolls, MessageReactions, MessageStars, MessengerCore, PushedHistory, ReadState, ScheduledMessages, ServerReads, TopicReadState } from "../ports/messenger.js";
1
+ import type { AccountEditing, AccountTools, ChatFolders, ChatReading, ContactBook, GroupAdmin, GroupModeration, LiveUpdates, MessageEditing, MessageMedia, MessagePins, MessagePolls, MessageReactions, MessageStars, MessengerCore, PassiveUpdates, PushedHistory, ReadState, ScheduledMessages, ServerReads, TopicReadState } from "../ports/messenger.js";
2
2
  import { type Seed } from "./seed.js";
3
3
  /** Every group of the port, over memory. */
4
- export type FakeAdapter = MessengerCore & ServerReads & ChatReading & MessageEditing & MessagePins & MessageStars & MessageReactions & ReadState & TopicReadState & MessagePolls & LiveUpdates & MessageMedia & ScheduledMessages & GroupModeration & AccountTools & GroupAdmin & ChatFolders & ContactBook & AccountEditing & Partial<PushedHistory>;
4
+ export type FakeAdapter = MessengerCore & ServerReads & ChatReading & MessageEditing & MessagePins & MessageStars & MessageReactions & ReadState & TopicReadState & MessagePolls & LiveUpdates & MessageMedia & ScheduledMessages & GroupModeration & AccountTools & GroupAdmin & ChatFolders & ContactBook & AccountEditing & Partial<PushedHistory> & Partial<PassiveUpdates>;
5
5
  export declare const fakeAdapter: (seed?: Seed, options?: {
6
6
  feed?: boolean;
7
+ listen?: boolean;
7
8
  }) => FakeAdapter;
package/dist/kit/fake.js CHANGED
@@ -28,6 +28,7 @@ const missing = (what) => new CliError("not_found", `the fake adapter has no ${w
28
28
  *
29
29
  * With `{ feed: true }` it also pushes its history, as a messenger with `history: "store"` does:
30
30
  * the chats and people in one batch, then each chat's messages in a batch of their own.
31
+ * With `{ listen: true }` it also has `listen`, which hands on what `watch` does.
31
32
  */
32
33
  const withRules = (folder, { emoji, include, skip, exclude, pin }) => {
33
34
  if (emoji !== undefined)
@@ -311,6 +312,17 @@ export const fakeAdapter = (seed = contractSeed(), options = {}) => {
311
312
  await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
312
313
  listeners.delete(onEvent);
313
314
  },
315
+ ...(options.listen
316
+ ? {
317
+ listen: async (onEvent, signal) => {
318
+ if (signal.aborted)
319
+ return;
320
+ listeners.add(onEvent);
321
+ await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
322
+ listeners.delete(onEvent);
323
+ },
324
+ }
325
+ : {}),
314
326
  ...(options.feed
315
327
  ? {
316
328
  feed: async (onBatch, signal) => {
@@ -4,7 +4,7 @@ import { identityPk } from "./identities.js";
4
4
  import { bound } from "./query-filter.js";
5
5
  import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
6
6
  import { ensureTag } from "./tags.js";
7
- import { json, parsed, present, toIso, toMs } from "./values.js";
7
+ import { parsed, present, toIso, toMs } from "./values.js";
8
8
  /** The chat's own id, when the store key is a chat of this account. */
9
9
  export const chatOf = ({ orm }, accountKey, chatKey) => orm
10
10
  .select({ id: chats.externalId })
@@ -31,7 +31,12 @@ export const chatPkFor = (context, accountKey, chatId) => findChatPk(context, ac
31
31
  .get()?.pk);
32
32
  export const upsertChat = (context, accountKey, chat) => {
33
33
  const { orm, now } = context;
34
- const parent = chat.parentChatId === undefined ? null : chatPkFor(context, accountKey, chat.parentChatId);
34
+ const parent = chat.parentChatId == null ? null : chatPkFor(context, accountKey, chat.parentChatId);
35
+ const stored = orm
36
+ .select({ metadata: chats.metadata })
37
+ .from(chats)
38
+ .where(and(eq(chats.accountId, accountKey), eq(chats.externalId, chat.id)))
39
+ .get();
35
40
  orm
36
41
  .insert(chats)
37
42
  .values({
@@ -44,7 +49,7 @@ export const upsertChat = (context, accountKey, chat) => {
44
49
  unreadCount: chat.unreadCount,
45
50
  lastMessageAt: toMs(chat.lastMessageAt),
46
51
  participantsCount: chat.participantsCount,
47
- metadata: json(chat.providerMetadata),
52
+ metadata: mergedMetadata(stored?.metadata ?? null, chat.providerMetadata),
48
53
  membershipState: chat.membershipState ?? null,
49
54
  createdAt: now(),
50
55
  updatedAt: now(),
@@ -53,7 +58,7 @@ export const upsertChat = (context, accountKey, chat) => {
53
58
  target: [chats.accountId, chats.externalId],
54
59
  set: {
55
60
  kind: sql `excluded.kind`,
56
- parentChatId: sql `coalesce(excluded.parent_chat_id, ${chats.parentChatId})`,
61
+ ...(chat.parentChatId === undefined ? {} : { parentChatId: sql `excluded.parent_chat_id` }),
57
62
  scope: sql `coalesce(excluded.scope, ${chats.scope})`,
58
63
  title: sql `excluded.title`,
59
64
  unreadCount: sql `excluded.unread_count`,
@@ -68,6 +73,22 @@ export const upsertChat = (context, accountKey, chat) => {
68
73
  })
69
74
  .run();
70
75
  };
76
+ const mergedMetadata = (stored, incoming) => {
77
+ if (incoming === undefined)
78
+ return stored;
79
+ const merged = Object.entries({ ...metadataObject(stored), ...incoming }).filter(([, value]) => value !== null);
80
+ return merged.length === 0 ? null : JSON.stringify(Object.fromEntries(merged));
81
+ };
82
+ // Stored metadata that is not an object has no keys to keep.
83
+ const metadataObject = (stored) => {
84
+ try {
85
+ const value = stored === null ? null : JSON.parse(stored);
86
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ };
71
92
  /** Replaces who is in the chat, whole. */
72
93
  export const writeMembers = (context, key, accountKey, chatId, memberIds) => {
73
94
  const chatKey = chatPkFor(context, accountKey, chatId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.345.0",
3
+ "version": "0.346.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",