zapo-js 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +14 -13
  2. package/dist/client/WaClient.d.ts +4 -0
  3. package/dist/client/WaClient.js +5 -1
  4. package/dist/client/coordinators/WaMessageCoordinator.d.ts +11 -4
  5. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +11 -0
  6. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +15 -5
  7. package/dist/client/messaging/ignore-key.d.ts +2 -1
  8. package/dist/client/messaging/ignore-key.js +24 -3
  9. package/dist/esm/client/WaClient.js +5 -1
  10. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -5
  11. package/dist/esm/client/messaging/ignore-key.js +25 -4
  12. package/dist/esm/store/cache/identity.cache.js +30 -5
  13. package/dist/esm/store/cache/privacy-token.cache.js +30 -5
  14. package/dist/esm/store/cache/sender-key.cache.js +38 -7
  15. package/dist/esm/store/cache/session.cache.js +30 -4
  16. package/dist/esm/store/createStore.js +39 -4
  17. package/dist/esm/store/memory/identity.store.js +28 -4
  18. package/dist/esm/store/memory/privacy-token.store.js +24 -7
  19. package/dist/esm/store/memory/sender-key.store.js +36 -7
  20. package/dist/esm/store/memory/session.store.js +41 -7
  21. package/dist/esm/util/collections.js +94 -0
  22. package/dist/esm/util/index.js +2 -2
  23. package/dist/message/primitives/media-retry.d.ts +11 -0
  24. package/dist/store/cache/identity.cache.d.ts +6 -4
  25. package/dist/store/cache/identity.cache.js +30 -5
  26. package/dist/store/cache/privacy-token.cache.d.ts +6 -4
  27. package/dist/store/cache/privacy-token.cache.js +30 -5
  28. package/dist/store/cache/sender-key.cache.d.ts +5 -3
  29. package/dist/store/cache/sender-key.cache.js +38 -7
  30. package/dist/store/cache/session.cache.d.ts +6 -3
  31. package/dist/store/cache/session.cache.js +30 -4
  32. package/dist/store/createStore.js +39 -4
  33. package/dist/store/memory/identity.store.d.ts +7 -1
  34. package/dist/store/memory/identity.store.js +28 -4
  35. package/dist/store/memory/privacy-token.store.d.ts +7 -1
  36. package/dist/store/memory/privacy-token.store.js +24 -7
  37. package/dist/store/memory/sender-key.store.d.ts +12 -2
  38. package/dist/store/memory/sender-key.store.js +36 -7
  39. package/dist/store/memory/session.store.d.ts +7 -1
  40. package/dist/store/memory/session.store.js +41 -7
  41. package/dist/store/types.d.ts +43 -4
  42. package/dist/util/collections.d.ts +46 -0
  43. package/dist/util/collections.js +98 -0
  44. package/dist/util/index.d.ts +2 -2
  45. package/dist/util/index.js +3 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -127,19 +127,20 @@ The core lives at the repo root (`zapo-js`). Optional packages live in
127
127
  [`packages/`](packages/) and ship under the `@zapo-js/*` scope. Install
128
128
  only what you need.
129
129
 
130
- | Package | Peer dependency | Purpose |
131
- | ---------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
132
- | [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
133
- | [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
134
- | [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
135
- | [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
136
- | [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
137
- | [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
138
- | [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
139
- | [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
140
- | [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
141
- | [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
142
- | [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
130
+ | Package | Peer dependency | Purpose |
131
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
132
+ | [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
133
+ | [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
134
+ | [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
135
+ | [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
136
+ | [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
137
+ | [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
138
+ | [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
139
+ | [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm-fork` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
140
+ | [`@zapo-js/voip-media`](packages/voip-media) | `libmlow-wasm-fork` (optional, to carry media) + `@roamhq/wrtc` (optional, Node host) | Call media plane with no Node or `zapo-js` dependency, so it runs in the browser: relay transport, STUN, SRTP, RTP/RTCP, MLow codec. |
141
+ | [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
142
+ | [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
143
+ | [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
143
144
 
144
145
  Each package's README has the install + config + integration notes.
145
146
 
@@ -132,6 +132,10 @@ declare class WaClientImpl extends EventEmitter {
132
132
  * arrays are empty. The predicate receives a parsed
133
133
  * {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
134
134
  *
135
+ * `category='peer'` messages from our own account (app-state key share,
136
+ * history sync, PDO responses) are never dropped: they are protocol
137
+ * traffic between our own devices, not chat messages.
138
+ *
135
139
  * @example
136
140
  * ```ts
137
141
  * client.ignoreKey({ remoteJid: spammerJid })
@@ -458,6 +458,10 @@ class WaClientImpl extends node_events_1.EventEmitter {
458
458
  * arrays are empty. The predicate receives a parsed
459
459
  * {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
460
460
  *
461
+ * `category='peer'` messages from our own account (app-state key share,
462
+ * history sync, PDO responses) are never dropped: they are protocol
463
+ * traffic between our own devices, not chat messages.
464
+ *
461
465
  * @example
462
466
  * ```ts
463
467
  * client.ignoreKey({ remoteJid: spammerJid })
@@ -469,7 +473,7 @@ class WaClientImpl extends node_events_1.EventEmitter {
469
473
  if (typeof input !== 'function') {
470
474
  (0, ignore_key_1.validateIgnoreKey)(input);
471
475
  }
472
- const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials()?.meJid);
476
+ const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials());
473
477
  return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
474
478
  }
475
479
  /** Auth client: pairing, credentials, registration state. */
@@ -384,10 +384,17 @@ export declare class WaMessageCoordinator {
384
384
  * the sealed request and resolves once the server answers with a
385
385
  * `mediaretry` notification, usually within a couple of seconds.
386
386
  *
387
- * On `result: 'success'` only the `directPath` changes - the media key,
388
- * hashes, and length of the original message stay valid, so patch the path
389
- * into the message and download again. A second call for a message already
390
- * in flight joins the first request instead of sending another receipt.
387
+ * On `result: 'success'` the media key, hashes, and length of the original
388
+ * message normally stay valid, so patch the fresh `directPath` into the
389
+ * message and download again. A second call for a message already in
390
+ * flight joins the first request instead of sending another receipt.
391
+ *
392
+ * A `success` is not a guarantee that the original key still opens the
393
+ * blob. Some primaries re-encrypt the file on every re-upload, and the
394
+ * re-served bytes then fail `fileEncSha256` and make `downloadBytes()`
395
+ * throw a MAC mismatch. Nothing in the round-trip carries key material for
396
+ * the new ciphertext, so the message cannot be recovered through this API
397
+ * and requesting another reupload will not help.
391
398
  *
392
399
  * The other three `result` values are answers too, not thrown errors:
393
400
  * `not_found` means the sender no longer holds the file and nothing can
@@ -7,6 +7,7 @@ import type { WaGroupEvent, WaOutgoingMessageEvent, WaSendMessageOptions, WaSign
7
7
  import type { Logger } from '../../infra/log/types';
8
8
  import { type WaSendContextInfo } from '../../message/context-info';
9
9
  import { type IcdcMeta } from '../../message/crypto/icdc';
10
+ import { type WaOutboundMessageAttrs } from '../../message/encode/content';
10
11
  import type { WaEncryptedMessageInput, WaMessageBuildResult, WaMessagePublishOptions, WaMessagePublishResult, WaSendMessageContent, WaSendReceiptInput } from '../../message/types';
11
12
  import type { WaMessageClient } from '../../message/WaMessageClient';
12
13
  import { type Proto } from '../../proto';
@@ -96,6 +97,16 @@ export interface WaGroupHistoryAudience {
96
97
  * while nobody received anything.
97
98
  */
98
99
  export declare function restrictGroupHistoryTargets(message: Proto.IMessage, participantUserJids: readonly string[], senderJid: string, onDropped?: (droppedJids: readonly string[], totalExpected: number) => void): readonly string[];
100
+ /**
101
+ * Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
102
+ * `<biz>` companion rides a type=text stanza with no mediatype, except on a
103
+ * group list: there the server nacks with 479 (SMAX_INVALID) unless
104
+ * type=media, mediatype=list and `<biz><list>` travel together.
105
+ */
106
+ export declare function resolveOutboundStanzaShape(attrs: Pick<WaOutboundMessageAttrs, 'buttonAddonKind' | 'typeAttr' | 'mediatype'>, isGroup: boolean): {
107
+ readonly type: string;
108
+ readonly mediatype: string | undefined;
109
+ };
99
110
  export declare class WaMessageDispatchCoordinator {
100
111
  private readonly deps;
101
112
  private readonly mobileMessageIdFormat;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaMessageDispatchCoordinator = void 0;
4
4
  exports.restrictGroupHistoryTargets = restrictGroupHistoryTargets;
5
+ exports.resolveOutboundStanzaShape = resolveOutboundStanzaShape;
5
6
  const _crypto_1 = require("../../crypto");
6
7
  const primitives_1 = require("../../crypto/core/primitives");
7
8
  const PromiseDedup_1 = require("../../infra/perf/PromiseDedup");
@@ -73,6 +74,19 @@ function restrictGroupHistoryTargets(message, participantUserJids, senderJid, on
73
74
  }
74
75
  return targets;
75
76
  }
77
+ /**
78
+ * Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
79
+ * `<biz>` companion rides a type=text stanza with no mediatype, except on a
80
+ * group list: there the server nacks with 479 (SMAX_INVALID) unless
81
+ * type=media, mediatype=list and `<biz><list>` travel together.
82
+ */
83
+ function resolveOutboundStanzaShape(attrs, isGroup) {
84
+ const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
85
+ if (flattensForAddon) {
86
+ return { type: 'text', mediatype: undefined };
87
+ }
88
+ return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
89
+ }
76
90
  class WaMessageDispatchCoordinator {
77
91
  constructor(options) {
78
92
  this.icdcDedup = new PromiseDedup_1.PromiseDedup();
@@ -403,12 +417,8 @@ class WaMessageDispatchCoordinator {
403
417
  const outboundAttrs = (0, content_1.resolveOutboundMessageAttrs)(messageWithIcdc);
404
418
  const buttonAddonKind = outboundAttrs.buttonAddonKind;
405
419
  const buttonAddonNode = buttonAddonKind ? (0, message_1.buildButtonAddonNode)(buttonAddonKind) : undefined;
406
- // when a <biz> companion is attached the stanza must advertise type=text and
407
- // omit enc.mediatype; sending type=media + mediatype=list/button alongside the
408
- // companion is rejected by the server as SMAX_INVALID (479).
409
- const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
420
+ const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
410
421
  const edit = outboundAttrs.edit ?? undefined;
411
- const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
412
422
  const metaAttrs = outboundAttrs.metaAttrs;
413
423
  const metaNode = metaAttrs ? (0, message_1.buildMetaNode)(metaAttrs) : undefined;
414
424
  const customNodes = [];
@@ -1,3 +1,4 @@
1
+ import type { WaAuthCredentials } from '../../auth/types';
1
2
  import type { WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIncomingStanzaFilter } from '../types';
2
3
  import type { BinaryNode } from '../../transport/types';
3
4
  export declare function validateIgnoreKey(d: WaIgnoreKey): void;
@@ -8,4 +9,4 @@ export declare function validateIgnoreKey(d: WaIgnoreKey): void;
8
9
  export declare function extractIgnoreKeyContext(node: BinaryNode, meJid: string | null | undefined): WaIgnoreKeyContext | null;
9
10
  /** Pure matcher. Exported for direct testing without a coordinator. */
10
11
  export declare function matchesIgnoreKey(node: BinaryNode, d: WaIgnoreKey, meJid: string | null | undefined): boolean;
11
- export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMeJid: () => string | null | undefined): WaIncomingStanzaFilter;
12
+ export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMe: () => Pick<WaAuthCredentials, 'meJid' | 'meLid'> | null | undefined): WaIncomingStanzaFilter;
@@ -123,12 +123,33 @@ function matchesIgnoreKey(node, d, meJid) {
123
123
  return false;
124
124
  return true;
125
125
  }
126
- function createIgnoreKeyFilter(input, getMeJid) {
126
+ /**
127
+ * A `category='peer'` message from our own account is traffic between our own
128
+ * devices (app-state key share, history sync, PDO responses), not a chat
129
+ * message, so it has no key to ignore by. Dropping it starves the protocol:
130
+ * app-state collections stay blocked on a key whose share never gets through.
131
+ * The own-account check keeps a foreign sender from dodging a filter by
132
+ * stamping the attribute.
133
+ */
134
+ function isOwnPeerMessage(node, me) {
135
+ if (node.tag !== message_1.WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
136
+ return false;
137
+ }
138
+ const from = node.attrs.from;
139
+ return !!from && tryParseJid(from) !== null && (0, jid_1.isOwnAccountJid)(from, me.meJid, me.meLid);
140
+ }
141
+ function createIgnoreKeyFilter(input, getMe) {
127
142
  if (typeof input === 'function') {
128
143
  return (node) => {
129
- const ctx = extractIgnoreKeyContext(node, getMeJid());
144
+ const me = getMe();
145
+ if (isOwnPeerMessage(node, me))
146
+ return false;
147
+ const ctx = extractIgnoreKeyContext(node, me?.meJid);
130
148
  return ctx !== null && input(ctx);
131
149
  };
132
150
  }
133
- return (node) => matchesIgnoreKey(node, input, getMeJid());
151
+ return (node) => {
152
+ const me = getMe();
153
+ return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
154
+ };
134
155
  }
@@ -455,6 +455,10 @@ class WaClientImpl extends EventEmitter {
455
455
  * arrays are empty. The predicate receives a parsed
456
456
  * {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
457
457
  *
458
+ * `category='peer'` messages from our own account (app-state key share,
459
+ * history sync, PDO responses) are never dropped: they are protocol
460
+ * traffic between our own devices, not chat messages.
461
+ *
458
462
  * @example
459
463
  * ```ts
460
464
  * client.ignoreKey({ remoteJid: spammerJid })
@@ -466,7 +470,7 @@ class WaClientImpl extends EventEmitter {
466
470
  if (typeof input !== 'function') {
467
471
  validateIgnoreKey(input);
468
472
  }
469
- const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials()?.meJid);
473
+ const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials());
470
474
  return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
471
475
  }
472
476
  /** Auth client: pairing, credentials, registration state. */
@@ -69,6 +69,19 @@ export function restrictGroupHistoryTargets(message, participantUserJids, sender
69
69
  }
70
70
  return targets;
71
71
  }
72
+ /**
73
+ * Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
74
+ * `<biz>` companion rides a type=text stanza with no mediatype, except on a
75
+ * group list: there the server nacks with 479 (SMAX_INVALID) unless
76
+ * type=media, mediatype=list and `<biz><list>` travel together.
77
+ */
78
+ export function resolveOutboundStanzaShape(attrs, isGroup) {
79
+ const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
80
+ if (flattensForAddon) {
81
+ return { type: 'text', mediatype: undefined };
82
+ }
83
+ return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
84
+ }
72
85
  export class WaMessageDispatchCoordinator {
73
86
  constructor(options) {
74
87
  this.icdcDedup = new PromiseDedup();
@@ -399,12 +412,8 @@ export class WaMessageDispatchCoordinator {
399
412
  const outboundAttrs = resolveOutboundMessageAttrs(messageWithIcdc);
400
413
  const buttonAddonKind = outboundAttrs.buttonAddonKind;
401
414
  const buttonAddonNode = buttonAddonKind ? buildButtonAddonNode(buttonAddonKind) : undefined;
402
- // when a <biz> companion is attached the stanza must advertise type=text and
403
- // omit enc.mediatype; sending type=media + mediatype=list/button alongside the
404
- // companion is rejected by the server as SMAX_INVALID (479).
405
- const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
415
+ const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
406
416
  const edit = outboundAttrs.edit ?? undefined;
407
- const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
408
417
  const metaAttrs = outboundAttrs.metaAttrs;
409
418
  const metaNode = metaAttrs ? buildMetaNode(metaAttrs) : undefined;
410
419
  const customNodes = [];
@@ -1,4 +1,4 @@
1
- import { parseJidFull } from '../../protocol/jid.js';
1
+ import { isOwnAccountJid, parseJidFull } from '../../protocol/jid.js';
2
2
  import { WA_MESSAGE_TAGS } from '../../protocol/message.js';
3
3
  import { WA_NODE_TAGS } from '../../protocol/nodes.js';
4
4
  const TAG_TO_KIND = {
@@ -117,12 +117,33 @@ export function matchesIgnoreKey(node, d, meJid) {
117
117
  return false;
118
118
  return true;
119
119
  }
120
- export function createIgnoreKeyFilter(input, getMeJid) {
120
+ /**
121
+ * A `category='peer'` message from our own account is traffic between our own
122
+ * devices (app-state key share, history sync, PDO responses), not a chat
123
+ * message, so it has no key to ignore by. Dropping it starves the protocol:
124
+ * app-state collections stay blocked on a key whose share never gets through.
125
+ * The own-account check keeps a foreign sender from dodging a filter by
126
+ * stamping the attribute.
127
+ */
128
+ function isOwnPeerMessage(node, me) {
129
+ if (node.tag !== WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
130
+ return false;
131
+ }
132
+ const from = node.attrs.from;
133
+ return !!from && tryParseJid(from) !== null && isOwnAccountJid(from, me.meJid, me.meLid);
134
+ }
135
+ export function createIgnoreKeyFilter(input, getMe) {
121
136
  if (typeof input === 'function') {
122
137
  return (node) => {
123
- const ctx = extractIgnoreKeyContext(node, getMeJid());
138
+ const me = getMe();
139
+ if (isOwnPeerMessage(node, me))
140
+ return false;
141
+ const ctx = extractIgnoreKeyContext(node, me?.meJid);
124
142
  return ctx !== null && input(ctx);
125
143
  };
126
144
  }
127
- return (node) => matchesIgnoreKey(node, input, getMeJid());
145
+ return (node) => {
146
+ const me = getMe();
147
+ return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
148
+ };
128
149
  }
@@ -1,18 +1,41 @@
1
1
  import { WaIdentityMemoryStore } from '../memory/identity.store.js';
2
+ import { IdleExpiryIndex } from '../../util/collections.js';
3
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
4
+ class IdleIdentityMemoryStore extends WaIdentityMemoryStore {
5
+ constructor(maxRemoteIdentities, expiry) {
6
+ super({ maxRemoteIdentities });
7
+ this.idle = new IdleExpiryIndex(this.remoteIdentities, expiry);
8
+ }
9
+ detach() {
10
+ this.idle.detach();
11
+ }
12
+ onEntryAccess(key) {
13
+ this.idle.touch(key);
14
+ }
15
+ onEntryRemove(key) {
16
+ this.idle.delete(key);
17
+ }
18
+ onEntriesClear() {
19
+ this.idle.clear();
20
+ }
21
+ }
2
22
  /**
3
23
  * Read-through / write-through in-process cache for a persistent remote
4
- * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded-LRU
5
- * L1. Remote identities are read alongside sessions on the send path (the
24
+ * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded L1.
25
+ * Remote identities are read alongside sessions on the send path (the
6
26
  * identity-mismatch guard), so caching them complements
7
- * {@link withSessionCache}.
27
+ * {@link withSessionCache}, which also describes the eviction with and
28
+ * without `expiry`.
8
29
  *
9
30
  * The identity store has no per-key delete: identities are overwritten on
10
31
  * re-establishment, so a peer's key change propagates through the
11
32
  * write-through `setRemoteIdentity`. See {@link withSessionCache} for the
12
33
  * shared coherence model and the single-writer-per-session assumption.
13
34
  */
14
- export function withIdentityCache(backend, maxEntries) {
15
- const l1 = new WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
35
+ export function withIdentityCache(backend, maxEntries, expiry) {
36
+ const l1 = expiry
37
+ ? new IdleIdentityMemoryStore(maxEntries, expiry)
38
+ : new WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
16
39
  let generation = 0;
17
40
  return {
18
41
  getRemoteIdentity: async (address) => {
@@ -68,6 +91,8 @@ export function withIdentityCache(backend, maxEntries) {
68
91
  await l1.clear();
69
92
  },
70
93
  destroy: async () => {
94
+ if (l1 instanceof IdleIdentityMemoryStore)
95
+ l1.detach();
71
96
  await l1.clear();
72
97
  await backend.destroy?.();
73
98
  }
@@ -1,18 +1,41 @@
1
1
  import { WaPrivacyTokenMemoryStore } from '../memory/privacy-token.store.js';
2
+ import { IdleExpiryIndex } from '../../util/collections.js';
3
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
4
+ class IdlePrivacyTokenMemoryStore extends WaPrivacyTokenMemoryStore {
5
+ constructor(maxEntries, expiry) {
6
+ super(maxEntries);
7
+ this.idle = new IdleExpiryIndex(this.records, expiry);
8
+ }
9
+ detach() {
10
+ this.idle.detach();
11
+ }
12
+ onEntryAccess(jid) {
13
+ this.idle.touch(jid);
14
+ }
15
+ onEntryRemove(jid) {
16
+ this.idle.delete(jid);
17
+ }
18
+ onEntriesClear() {
19
+ this.idle.clear();
20
+ }
21
+ }
2
22
  /**
3
23
  * Read-through cache for a persistent privacy-token backend. Reuses
4
- * {@link WaPrivacyTokenMemoryStore} as the bounded-LRU L1.
24
+ * {@link WaPrivacyTokenMemoryStore} as the bounded L1.
5
25
  *
6
26
  * Unlike the signal caches this is **invalidate-on-write, not
7
27
  * write-through**: `upsert` merges partial fields into the existing row on
8
28
  * the backend, so caching the partial incoming record would diverge from the
9
29
  * backend's merged result. Instead each upsert drops the L1 entry and the
10
30
  * next read re-populates from the merged backend truth. See
11
- * {@link withSessionCache} for the shared coherence model and the
12
- * single-writer-per-session assumption.
31
+ * {@link withSessionCache} for the shared coherence model, the
32
+ * single-writer-per-session assumption and the eviction with and without
33
+ * `expiry`.
13
34
  */
14
- export function withPrivacyTokenCache(backend, maxEntries) {
15
- const l1 = new WaPrivacyTokenMemoryStore(maxEntries);
35
+ export function withPrivacyTokenCache(backend, maxEntries, expiry) {
36
+ const l1 = expiry
37
+ ? new IdlePrivacyTokenMemoryStore(maxEntries, expiry)
38
+ : new WaPrivacyTokenMemoryStore(maxEntries);
16
39
  let generation = 0;
17
40
  return {
18
41
  upsert: async (record) => {
@@ -50,6 +73,8 @@ export function withPrivacyTokenCache(backend, maxEntries) {
50
73
  await l1.clear();
51
74
  },
52
75
  destroy: async () => {
76
+ if (l1 instanceof IdlePrivacyTokenMemoryStore)
77
+ l1.detach();
53
78
  await l1.clear();
54
79
  await backend.destroy?.();
55
80
  }
@@ -1,7 +1,33 @@
1
1
  import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
2
+ import { IdleExpiryIndex } from '../../util/collections.js';
3
+ /** L1 with idle expiry; hits keep both maps in access order, so the caps evict LRU entries. */
4
+ class IdleSenderKeyMemoryStore extends SenderKeyMemoryStore {
5
+ constructor(maxEntries, expiry) {
6
+ super({ maxSenderKeys: maxEntries, maxSenderDistributions: maxEntries });
7
+ this.idleSenderKeys = new IdleExpiryIndex(this.senderKeys, expiry);
8
+ this.idleDistributions = new IdleExpiryIndex(this.senderDistributions, expiry);
9
+ }
10
+ detach() {
11
+ this.idleSenderKeys.detach();
12
+ this.idleDistributions.detach();
13
+ }
14
+ onEntryAccess(entries, key) {
15
+ this.indexFor(entries).touch(key);
16
+ }
17
+ onEntryRemove(entries, key) {
18
+ this.indexFor(entries).delete(key);
19
+ }
20
+ onEntriesClear() {
21
+ this.idleSenderKeys.clear();
22
+ this.idleDistributions.clear();
23
+ }
24
+ indexFor(entries) {
25
+ return entries === this.senderKeys ? this.idleSenderKeys : this.idleDistributions;
26
+ }
27
+ }
2
28
  /**
3
29
  * Read-through / write-through in-process cache for a persistent sender-key
4
- * backend. Reuses {@link SenderKeyMemoryStore} as the bounded-LRU L1 so the
30
+ * backend. Reuses {@link SenderKeyMemoryStore} as the bounded L1 so the
5
31
  * per-(group, sender) lookups repeated across a group fan-out skip the
6
32
  * backend round-trip.
7
33
  *
@@ -12,13 +38,16 @@ import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
12
38
  * (`deleteDeviceSenderKey`/`markForgetSenderKey`) run on the backend for the
13
39
  * authoritative count and then invalidate the matching L1 entries via the
14
40
  * memory store's own sweep. See {@link withSessionCache} for the shared
15
- * coherence model and the single-writer-per-session assumption.
41
+ * coherence model, the single-writer-per-session assumption and the eviction
42
+ * with and without `expiry`.
16
43
  */
17
- export function withSenderKeyCache(backend, maxEntries) {
18
- const l1 = new SenderKeyMemoryStore({
19
- maxSenderKeys: maxEntries,
20
- maxSenderDistributions: maxEntries
21
- });
44
+ export function withSenderKeyCache(backend, maxEntries, expiry) {
45
+ const l1 = expiry
46
+ ? new IdleSenderKeyMemoryStore(maxEntries, expiry)
47
+ : new SenderKeyMemoryStore({
48
+ maxSenderKeys: maxEntries,
49
+ maxSenderDistributions: maxEntries
50
+ });
22
51
  let generation = 0;
23
52
  return {
24
53
  upsertSenderKey: async (record) => {
@@ -94,6 +123,8 @@ export function withSenderKeyCache(backend, maxEntries) {
94
123
  await l1.clear();
95
124
  },
96
125
  destroy: async () => {
126
+ if (l1 instanceof IdleSenderKeyMemoryStore)
127
+ l1.detach();
97
128
  await l1.clear();
98
129
  await backend.destroy?.();
99
130
  }
@@ -1,9 +1,31 @@
1
1
  import { WaSessionMemoryStore } from '../memory/session.store.js';
2
+ import { IdleExpiryIndex } from '../../util/collections.js';
3
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
4
+ class IdleSessionMemoryStore extends WaSessionMemoryStore {
5
+ constructor(maxSessions, expiry) {
6
+ super({ maxSessions });
7
+ this.idle = new IdleExpiryIndex(this.signalSessions, expiry);
8
+ }
9
+ detach() {
10
+ this.idle.detach();
11
+ }
12
+ onEntryAccess(key) {
13
+ this.idle.touch(key);
14
+ }
15
+ onEntryRemove(key) {
16
+ this.idle.delete(key);
17
+ }
18
+ onEntriesClear() {
19
+ this.idle.clear();
20
+ }
21
+ }
2
22
  /**
3
23
  * Read-through / write-through in-process cache for a persistent session
4
- * backend. Reuses {@link WaSessionMemoryStore} as the bounded-LRU L1 so that
24
+ * backend. Reuses {@link WaSessionMemoryStore} as the bounded L1 so that
5
25
  * repeated reads of the same peer on the send/recv path skip the backend
6
- * round-trip.
26
+ * round-trip. Without `expiry` the cap evicts the least recently written
27
+ * entry; with it, the least recently used, and entries idle past the TTL
28
+ * leave the L1 (never the backend) and are re-read on next use.
7
29
  *
8
30
  * Coherence model (single process):
9
31
  * - every mutation (set/delete/clear) writes the backend then the L1 in
@@ -19,8 +41,10 @@ import { WaSessionMemoryStore } from '../memory/session.store.js';
19
41
  * session with the cache enabled - there is no cross-process invalidation
20
42
  * channel, so another process's writes would leave this L1 stale.
21
43
  */
22
- export function withSessionCache(backend, maxEntries) {
23
- const l1 = new WaSessionMemoryStore({ maxSessions: maxEntries });
44
+ export function withSessionCache(backend, maxEntries, expiry) {
45
+ const l1 = expiry
46
+ ? new IdleSessionMemoryStore(maxEntries, expiry)
47
+ : new WaSessionMemoryStore({ maxSessions: maxEntries });
24
48
  let generation = 0;
25
49
  return {
26
50
  hasSession: async (address) => {
@@ -85,6 +109,8 @@ export function withSessionCache(backend, maxEntries) {
85
109
  await l1.clear();
86
110
  },
87
111
  destroy: async () => {
112
+ if (l1 instanceof IdleSessionMemoryStore)
113
+ l1.detach();
88
114
  await l1.clear();
89
115
  await backend.destroy?.();
90
116
  }
@@ -35,6 +35,7 @@ import { WaSignalMemoryStore } from './memory/signal.store.js';
35
35
  import { WaThreadMemoryStore } from './memory/thread.store.js';
36
36
  import { NOOP_CHAT_METADATA_STORE, NOOP_CONTACT_STORE, NOOP_DEVICE_LIST_STORE, NOOP_GROUP_METADATA_STORE, NOOP_MESSAGE_SECRET_STORE, NOOP_MESSAGE_STORE, NOOP_RETRY_STORE, NOOP_THREAD_STORE } from './noop.store.js';
37
37
  import { resolvePositive } from '../util/coercion.js';
38
+ import { IdleSweepClock, resolveIdleSweepPeriodMs } from '../util/collections.js';
38
39
  import { toError } from '../util/primitives.js';
39
40
  const DEFAULT_CACHE_TTLS_MS = Object.freeze({
40
41
  retryMs: 60 * 1000,
@@ -56,6 +57,8 @@ const REQUIRED_PROVIDER_DOMAINS = [
56
57
  'contacts',
57
58
  'privacyToken'
58
59
  ];
60
+ const CACHE_LAYER_DOMAINS = ['session', 'identity', 'senderKey', 'privacyToken'];
61
+ const MIN_CACHE_LAYER_TTL_MS = 1000;
59
62
  function hasDestroy(value) {
60
63
  return (!!value &&
61
64
  typeof value === 'object' &&
@@ -70,6 +73,37 @@ async function destroyIfSupported(value) {
70
73
  function usesBackend(provider) {
71
74
  return !!provider && provider !== 'memory' && provider !== 'none';
72
75
  }
76
+ /**
77
+ * Validates `cacheLayer.ttlMs` and hands each domain that gets an L1 its idle
78
+ * expiry, all on one clock. A TTL on a domain without an L1 is warned and dropped.
79
+ */
80
+ function resolveCacheLayerExpiry(cacheLayer, providers, logger) {
81
+ const ttls = [];
82
+ for (const domain of CACHE_LAYER_DOMAINS) {
83
+ const ttlMs = cacheLayer.ttlMs?.[`${domain}Ms`];
84
+ if (ttlMs === undefined)
85
+ continue;
86
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < MIN_CACHE_LAYER_TTL_MS) {
87
+ throw new Error(`cacheLayer.ttlMs.${domain}Ms must be a safe integer >= ${MIN_CACHE_LAYER_TTL_MS}`);
88
+ }
89
+ if (cacheLayer[domain] && usesBackend(providers[domain])) {
90
+ ttls.push({ domain, ttlMs });
91
+ continue;
92
+ }
93
+ logger?.warn('cacheLayer ttl ignored: domain has no L1', {
94
+ domain,
95
+ cached: cacheLayer[domain] === true,
96
+ provider: providers[domain] ?? 'memory'
97
+ });
98
+ }
99
+ const expiry = {};
100
+ if (ttls.length === 0)
101
+ return expiry;
102
+ const clock = new IdleSweepClock(resolveIdleSweepPeriodMs(ttls.map((entry) => entry.ttlMs)));
103
+ for (const { domain, ttlMs } of ttls)
104
+ expiry[domain] = { clock, ttlMs };
105
+ return expiry;
106
+ }
73
107
  function resolveStore(sessionId, backends, provider, domain, kind, fallback) {
74
108
  if (!provider || provider === 'memory' || provider === 'none') {
75
109
  return fallback();
@@ -112,6 +146,7 @@ export function createStore(options) {
112
146
  deviceList: resolvePositive(options.memory?.cacheTtlMs?.deviceListMs, DEFAULT_CACHE_TTLS_MS.deviceListMs, 'memory.cacheTtlMs.deviceListMs'),
113
147
  messageSecret: resolvePositive(options.memory?.cacheTtlMs?.messageSecretMs, DEFAULT_CACHE_TTLS_MS.messageSecretMs, 'memory.cacheTtlMs.messageSecretMs')
114
148
  });
149
+ const cacheLayerExpiry = resolveCacheLayerExpiry(cacheLayer, providers, storeLogger);
115
150
  const sessions = new Map();
116
151
  const pendingSessionDestroys = new Set();
117
152
  let storeDestroyed = false;
@@ -208,20 +243,20 @@ export function createStore(options) {
208
243
  const signalStore = withSignalLock(rawSignal);
209
244
  const preKeyStore = withPreKeyLock(rawPreKey);
210
245
  const sessionStore = withSessionLock(cacheLayer.session && usesBackend(providers.session)
211
- ? withSessionCache(rawSession, cacheLayer.limits?.session)
246
+ ? withSessionCache(rawSession, cacheLayer.limits?.session, cacheLayerExpiry.session)
212
247
  : rawSession);
213
248
  const identityStore = withIdentityLock(cacheLayer.identity && usesBackend(providers.identity)
214
- ? withIdentityCache(rawIdentity, cacheLayer.limits?.identity)
249
+ ? withIdentityCache(rawIdentity, cacheLayer.limits?.identity, cacheLayerExpiry.identity)
215
250
  : rawIdentity);
216
251
  const senderKeyStore = withSenderKeyLock(cacheLayer.senderKey && usesBackend(providers.senderKey)
217
- ? withSenderKeyCache(rawSenderKey, cacheLayer.limits?.senderKey)
252
+ ? withSenderKeyCache(rawSenderKey, cacheLayer.limits?.senderKey, cacheLayerExpiry.senderKey)
218
253
  : rawSenderKey);
219
254
  const appStateStore = withAppStateLock(rawAppState);
220
255
  const messageStore = withMessageLock(rawMessages);
221
256
  const threadStore = withThreadLock(rawThreads);
222
257
  const contactStore = withContactLock(rawContacts);
223
258
  const privacyTokenStore = withPrivacyTokenLock(cacheLayer.privacyToken && usesBackend(providers.privacyToken)
224
- ? withPrivacyTokenCache(rawPrivacyToken, cacheLayer.limits?.privacyToken)
259
+ ? withPrivacyTokenCache(rawPrivacyToken, cacheLayer.limits?.privacyToken, cacheLayerExpiry.privacyToken)
225
260
  : rawPrivacyToken);
226
261
  let sessionDestroyed = false;
227
262
  let destroyPromise = null;