Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/calm-lids-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@zapo-js/store-mongo': patch
'@zapo-js/store-mysql': patch
'@zapo-js/store-postgres': patch
'@zapo-js/store-redis': patch
'@zapo-js/store-sqlite': patch
---

Persist PN/LID Signal-address mappings alongside configured sessions so both aliases share canonical state.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ client.on('message', async (event) => {
await client.connect()
```

The Signal `session` provider also owns the internal PN/LID mapping used to
keep ratchets canonical when WhatsApp alternates between phone-number and LID
addressing. Official persistent backends store that mapping automatically.

That's the minimum to pair, listen for messages, and reply. For everything
else - sending media, reactions, polls, groups, newsletters, app-state
mutations, business profile, events catalog, store providers, the typed
Expand Down
51 changes: 51 additions & 0 deletions packages/store-mongo/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,57 @@ describe('store-mongo integration', { timeout: 60_000 }, () => {
await senderKey.clear()
})

it('signal: PN/LID mappings are replaceable and session-scoped', async (t) => {
if (!store) return t.skip('ZAPO_TEST_MONGO_* not set')

const mappingA = store.stores.lidPnMapping(nextSessionId('lid-pn-a'))
const mappingB = store.stores.lidPnMapping(nextSessionId('lid-pn-b'))
await Promise.all([mappingA.clear(), mappingB.clear()])

assert.equal(await mappingA.getLidUser('5511999999999'), null)
await mappingA.setLidUser('5511999999999', '111222')
assert.equal(await mappingA.getLidUser('5511999999999'), '111222')
assert.equal(await mappingA.getPnUser('111222'), '5511999999999')
assert.equal(await mappingB.getLidUser('5511999999999'), null)
await mappingA.setLidUser('5511999999999', '333444')
assert.equal(await mappingA.getLidUser('5511999999999'), '333444')
assert.equal(await mappingA.getPnUser('111222'), null)
await mappingA.setLidUser('5511888888888', '333444')
assert.equal(await mappingA.getLidUser('5511999999999'), null)
assert.equal(await mappingA.getPnUser('333444'), '5511888888888')
await mappingA.clear()
assert.equal(await mappingA.getLidUser('5511888888888'), null)
assert.equal(await mappingA.getPnUser('333444'), null)
})

it('signal: concurrent PN/LID replacements preserve one owner', async (t) => {
if (!store) return t.skip('ZAPO_TEST_MONGO_* not set')

const sessionId = nextSessionId('lid-pn-concurrent')
const mappingA = store.stores.lidPnMapping(sessionId)
const mappingB = store.stores.lidPnMapping(sessionId)
await mappingA.clear()

for (let index = 0; index < 5; index += 1) {
const lidUser = `55566${index}`
const pnUsers = [`55117777777${index}`, `55116666666${index}`]
await Promise.all([
mappingA.setLidUser(pnUsers[0], lidUser),
mappingB.setLidUser(pnUsers[1], lidUser)
])

const owner = await mappingA.getPnUser(lidUser)
assert.ok(owner === pnUsers[0] || owner === pnUsers[1])
assert.equal(await mappingA.getLidUser(owner), lidUser)
assert.equal(
await mappingA.getLidUser(owner === pnUsers[0] ? pnUsers[1] : pnUsers[0]),
null
)
}

await mappingA.clear()
})

it('signal: session lifecycle and batch queries', async (t) => {
if (!store) return t.skip('ZAPO_TEST_MONGO_* not set')

Expand Down
6 changes: 5 additions & 1 deletion packages/store-mongo/src/createMongoStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WaContactMongoStore } from './contact.store'
import { WaDeviceListMongoStore } from './device-list.store'
import { WaGroupMetadataMongoStore } from './group-metadata.store'
import { WaIdentityMongoStore } from './identity.store'
import { WaLidPnMappingMongoStore } from './lid-pn-mapping.store'
import { WaMessageSecretMongoStore } from './message-secret.store'
import { WaMessageMongoStore } from './message.store'
import { WaPreKeyMongoStore } from './pre-key.store'
Expand Down Expand Up @@ -69,6 +70,7 @@ export interface WaMongoStoreResult {
readonly preKey: (sessionId: string) => WaPreKeyMongoStore
readonly session: (sessionId: string) => WaSessionMongoStore
readonly identity: (sessionId: string) => WaIdentityMongoStore
readonly lidPnMapping: (sessionId: string) => WaLidPnMappingMongoStore
readonly signal: (sessionId: string) => WaSignalMongoStore
readonly senderKey: (sessionId: string) => WaSenderKeyMongoStore
readonly appState: (sessionId: string) => WaAppStateMongoStore
Expand All @@ -91,7 +93,7 @@ function isDb(value: WaMongoStoreConfig['db']): value is Db {
}

/**
* Builds a MongoDB-backed {@link WaStoreBackend} bundle. All 11 persistent
* Builds a MongoDB-backed {@link WaStoreBackend} bundle. All 12 persistent
* domains + 4 cache domains live in a single database (split into
* collections by `collectionPrefix`).
*
Expand Down Expand Up @@ -165,6 +167,8 @@ export function createMongoStore(config: WaMongoStoreConfig): WaMongoStoreResult
preKey: (sessionId) => new WaPreKeyMongoStore(opts(sessionId, 'preKey')),
session: (sessionId) => new WaSessionMongoStore(opts(sessionId, 'session')),
identity: (sessionId) => new WaIdentityMongoStore(opts(sessionId, 'identity')),
lidPnMapping: (sessionId) =>
new WaLidPnMappingMongoStore(opts(sessionId, 'lidPnMapping')),
signal: (sessionId) => new WaSignalMongoStore(opts(sessionId, 'signal')),
senderKey: (sessionId) => new WaSenderKeyMongoStore(opts(sessionId, 'senderKey')),
appState: (sessionId) => new WaAppStateMongoStore(opts(sessionId, 'appState')),
Expand Down
1 change: 1 addition & 0 deletions packages/store-mongo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { WaAuthMongoStore } from './auth.store'
export { WaPreKeyMongoStore } from './pre-key.store'
export { WaSessionMongoStore } from './session.store'
export { WaIdentityMongoStore } from './identity.store'
export { WaLidPnMappingMongoStore } from './lid-pn-mapping.store'
export { WaSignalMongoStore } from './signal.store'
export { WaSenderKeyMongoStore } from './sender-key.store'
export { WaAppStateMongoStore } from './appstate.store'
Expand Down
102 changes: 102 additions & 0 deletions packages/store-mongo/src/lid-pn-mapping.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { WaLidPnMappingStore } from 'zapo-js/store'

import { BaseMongoStore } from './BaseMongoStore'
import type { WaMongoStorageOptions } from './types'

const COLLECTION = 'signal_lid_pn_mappings'
const REPLACE_MAX_ATTEMPTS = 3

interface LidPnMappingDoc {
_id: { session_id: string; pn_user: string }
lid_user: string
}

function isDuplicateKeyError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { readonly code?: unknown }).code === 11_000
)
}

/** MongoDB-backed PN/LID mapping store scoped by Zapo session id. */
export class WaLidPnMappingMongoStore extends BaseMongoStore implements WaLidPnMappingStore {
private writeTail: Promise<void> = Promise.resolve()

public constructor(options: WaMongoStorageOptions) {
super(options)
}

protected override async createIndexes(): Promise<void> {
await this.col<LidPnMappingDoc>(COLLECTION).createIndex(
{ '_id.session_id': 1, lid_user: 1 },
{ unique: true }
)
}

public async getLidUser(pnUser: string): Promise<string | null> {
await this.ensureIndexes()
const doc = await this.col<LidPnMappingDoc>(COLLECTION).findOne({
_id: { session_id: this.sessionId, pn_user: pnUser }
})
return doc?.lid_user ?? null
}

public async getPnUser(lidUser: string): Promise<string | null> {
await this.ensureIndexes()
const doc = await this.col<LidPnMappingDoc>(COLLECTION).findOne({
'_id.session_id': this.sessionId,
lid_user: lidUser
})
return doc?._id.pn_user ?? null
}

public async setLidUser(pnUser: string, lidUser: string): Promise<void> {
await this.runWriteSerialized(async () => {
for (let attempt = 1; attempt <= REPLACE_MAX_ATTEMPTS; attempt += 1) {
try {
await this.withSession(async (session) => {
const collection = this.col<LidPnMappingDoc>(COLLECTION)
await collection.deleteMany(
{
'_id.session_id': this.sessionId,
'_id.pn_user': { $ne: pnUser },
lid_user: lidUser
},
{ session }
)
await collection.updateOne(
{ _id: { session_id: this.sessionId, pn_user: pnUser } },
{ $set: { lid_user: lidUser } },
{ upsert: true, session }
)
})
return
} catch (error) {
if (attempt === REPLACE_MAX_ATTEMPTS || !isDuplicateKeyError(error)) {
throw error
}
}
}
})
}

public async clear(): Promise<void> {
await this.runWriteSerialized(async () => {
await this.ensureIndexes()
await this.col<LidPnMappingDoc>(COLLECTION).deleteMany({
'_id.session_id': this.sessionId
})
})
}

private runWriteSerialized<T>(task: () => Promise<T>): Promise<T> {
const result = this.writeTail.then(task)
this.writeTail = result.then(
() => undefined,
() => undefined
)
return result
}
}
24 changes: 24 additions & 0 deletions packages/store-mysql/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('store-mysql integration', { timeout: 60_000 }, () => {
await ensureMysqlMigrations(pool, [
'auth',
'signal',
'lidPnMapping',
'senderKey',
'appState',
'retry',
Expand Down Expand Up @@ -1080,6 +1081,29 @@ describe('store-mysql integration', { timeout: 60_000 }, () => {
await senderKey.clear()
})

it('signal: PN/LID mappings are replaceable and session-scoped', async (t) => {
if (!store) return t.skip('ZAPO_TEST_MYSQL_* not set')

const mappingA = store.stores.lidPnMapping(nextSessionId('lid-pn-a'))
const mappingB = store.stores.lidPnMapping(nextSessionId('lid-pn-b'))
await Promise.all([mappingA.clear(), mappingB.clear()])

assert.equal(await mappingA.getLidUser('5511999999999'), null)
await mappingA.setLidUser('5511999999999', '111222')
assert.equal(await mappingA.getLidUser('5511999999999'), '111222')
assert.equal(await mappingA.getPnUser('111222'), '5511999999999')
assert.equal(await mappingB.getLidUser('5511999999999'), null)
await mappingA.setLidUser('5511999999999', '333444')
assert.equal(await mappingA.getLidUser('5511999999999'), '333444')
assert.equal(await mappingA.getPnUser('111222'), null)
await mappingA.setLidUser('5511888888888', '333444')
assert.equal(await mappingA.getLidUser('5511999999999'), null)
assert.equal(await mappingA.getPnUser('333444'), '5511888888888')
await mappingA.clear()
assert.equal(await mappingA.getLidUser('5511888888888'), null)
assert.equal(await mappingA.getPnUser('333444'), null)
})

it('signal: session lifecycle and batch queries', async (t) => {
if (!store) return t.skip('ZAPO_TEST_MYSQL_* not set')

Expand Down
13 changes: 13 additions & 0 deletions packages/store-mysql/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,19 @@ const MIGRATIONS: readonly Migration[] = [
ALTER TABLE \`__PREFIX__retry_inbound_counters\`
DROP COLUMN updated_at_ms
`
},
{
name: '0018_signal_lid_pn_mapping',
domain: 'lidPnMapping',
sql: `
CREATE TABLE IF NOT EXISTS \`__PREFIX__signal_lid_pn_mapping\` (
session_id VARCHAR(128) NOT NULL,
pn_user VARCHAR(128) NOT NULL,
lid_user VARCHAR(128) NOT NULL,
PRIMARY KEY (session_id, pn_user),
UNIQUE KEY uq_signal_lid_pn_mapping_lid (session_id, lid_user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
}
]

Expand Down
4 changes: 4 additions & 0 deletions packages/store-mysql/src/createMysqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { WaContactMysqlStore } from './contact.store'
import { WaDeviceListMysqlStore } from './device-list.store'
import { WaGroupMetadataMysqlStore } from './group-metadata.store'
import { WaIdentityMysqlStore } from './identity.store'
import { WaLidPnMappingMysqlStore } from './lid-pn-mapping.store'
import { WaMessageSecretMysqlStore } from './message-secret.store'
import { WaMessageMysqlStore } from './message.store'
import { WaPreKeyMysqlStore } from './pre-key.store'
Expand Down Expand Up @@ -90,6 +91,7 @@ export interface WaMysqlStoreResult {
readonly preKey: (sessionId: string) => WaPreKeyMysqlStore
readonly session: (sessionId: string) => WaSessionMysqlStore
readonly identity: (sessionId: string) => WaIdentityMysqlStore
readonly lidPnMapping: (sessionId: string) => WaLidPnMappingMysqlStore
readonly signal: (sessionId: string) => WaSignalMysqlStore
readonly senderKey: (sessionId: string) => WaSenderKeyMysqlStore
readonly appState: (sessionId: string) => WaAppStateMysqlStore
Expand Down Expand Up @@ -172,6 +174,8 @@ export function createMysqlStore(config: WaMysqlStoreConfig): WaMysqlStoreResult
preKey: (sessionId) => new WaPreKeyMysqlStore(opts(sessionId, 'preKey')),
session: (sessionId) => new WaSessionMysqlStore(opts(sessionId, 'session')),
identity: (sessionId) => new WaIdentityMysqlStore(opts(sessionId, 'identity')),
lidPnMapping: (sessionId) =>
new WaLidPnMappingMysqlStore(opts(sessionId, 'lidPnMapping')),
signal: (sessionId) => new WaSignalMysqlStore(opts(sessionId, 'signal')),
senderKey: (sessionId) => new WaSenderKeyMysqlStore(opts(sessionId, 'senderKey')),
appState: (sessionId) => new WaAppStateMysqlStore(opts(sessionId, 'appState')),
Expand Down
1 change: 1 addition & 0 deletions packages/store-mysql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { WaAuthMysqlStore } from './auth.store'
export { WaPreKeyMysqlStore } from './pre-key.store'
export { WaSessionMysqlStore } from './session.store'
export { WaIdentityMysqlStore } from './identity.store'
export { WaLidPnMappingMysqlStore } from './lid-pn-mapping.store'
export { WaSignalMysqlStore } from './signal.store'
export { WaSenderKeyMysqlStore } from './sender-key.store'
export { WaAppStateMysqlStore } from './appstate.store'
Expand Down
61 changes: 61 additions & 0 deletions packages/store-mysql/src/lid-pn-mapping.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { WaLidPnMappingStore } from 'zapo-js/store'

import { BaseMysqlStore } from './BaseMysqlStore'
import { queryFirst } from './helpers'
import type { WaMysqlStorageOptions } from './types'

/** MySQL-backed PN/LID mapping store scoped by Zapo session id. */
export class WaLidPnMappingMysqlStore extends BaseMysqlStore implements WaLidPnMappingStore {
public constructor(options: WaMysqlStorageOptions) {
super(options, ['lidPnMapping'])
}

public async getLidUser(pnUser: string): Promise<string | null> {
await this.ensureReady()
const row = queryFirst(
await this.pool.execute(
`SELECT lid_user
FROM ${this.t('signal_lid_pn_mapping')}
WHERE session_id = ? AND pn_user = ?`,
[this.sessionId, pnUser]
)
)
return row ? String(row.lid_user) : null
}

public async getPnUser(lidUser: string): Promise<string | null> {
await this.ensureReady()
const row = queryFirst(
await this.pool.execute(
`SELECT pn_user
FROM ${this.t('signal_lid_pn_mapping')}
WHERE session_id = ? AND lid_user = ?`,
[this.sessionId, lidUser]
)
)
return row ? String(row.pn_user) : null
}

public async setLidUser(pnUser: string, lidUser: string): Promise<void> {
await this.withTransaction(async (connection) => {
await connection.execute(
`DELETE FROM ${this.t('signal_lid_pn_mapping')}
WHERE session_id = ? AND (pn_user = ? OR lid_user = ?)`,
[this.sessionId, pnUser, lidUser]
)
await connection.execute(
`INSERT INTO ${this.t('signal_lid_pn_mapping')} (session_id, pn_user, lid_user)
VALUES (?, ?, ?)`,
[this.sessionId, pnUser, lidUser]
)
})
}

public async clear(): Promise<void> {
await this.ensureReady()
await this.pool.execute(
`DELETE FROM ${this.t('signal_lid_pn_mapping')} WHERE session_id = ?`,
[this.sessionId]
)
}
}
1 change: 1 addition & 0 deletions packages/store-mysql/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type MysqlParam = string | number | bigint | Uint8Array | boolean | null
export type WaMysqlMigrationDomain =
| 'auth'
| 'signal'
| 'lidPnMapping'
| 'senderKey'
| 'appState'
| 'retry'
Expand Down
Loading
Loading