Who the profile belongs to
- The key is the canonical sender (
ctx.sender, thesenderIdthe provider resolved), so the same person sees the same data in their DM and in every group. - It lives inside the bot’s store namespace (tenant / environment / bot / provider / connection): two bots sharing one backend never see each other’s profiles.
Outside handlers and inside flows
userOf(ctx) throws UserDataError('UNAVAILABLE') synchronously when the
context was not built by a bot. In bot.conversation(name, async (c, ctx) => ...)
the ctx is a full MessageContext, so ctx.user works directly.
API
ctx.user itself is the 'default' namespace.
Paths and values
- A path splits on every dot. The first segment is the top-level key,
the unit the store persists atomically; deeper segments live inside its
JSON value. Arrays are indexed (
'items.0') and can only grow at the end (index ≤ length). - Rejected with
UserDataError('INVALID_PATH'): empty segments,__proto__/prototype/constructor, control characters and keys longer than 255 characters. Namespaces are 1–128 characters with no control characters (INVALID_NAMESPACE). - Values are JSON: they are copied through JSON on write (a
Datebecomes an ISO string) and every read returns a fresh copy. BigInt, functions, symbols and cycles reject withINVALID_VALUE. - Ids, namespaces and keys are compared as exact strings: case, accents and leading or trailing spaces matter, in every store.
TTL (Redis model)
- With
ttl, the top-level key expiresttlafter that write, even if the path is nested. - A top-level
setwithoutttlreplaces the value and removes any expiry. - A nested write without
ttl(includingdelete('a.b')) keeps the current expiry. - The TTL must be positive and finite (
INVALID_TTLotherwise); fractional milliseconds are rounded up. - An expired key reads as absent right away. It is removed physically by
Redis (native expiry) or by
bot.purgeExpiredUserData(), which writes trigger at most once a minute. For quiet bots, schedule it:bot.every('1h', () => bot.purgeExpiredUserData()).
Batches and concurrency
update()validates every path, value, array index and thettlbefore writing anything, so an invalid batch writes nothing. All the paths under one top-level key become one store write. The batch as a whole is not atomic: if the store fails one key, others may already be written; the call rejects only after every write has settled.- The per-chat FIFO does not serialize one user writing from several
chats, so
ctx.userserializes every write of a user inside the process (set/update/delete/clearand subject deletion), in call order. - With several replicas on one store, top-level writes stay atomic (last writer wins), but two replicas writing nested paths of the same top-level key at the same time can lose an update.
- Reads do not wait for writes:
awaityour writes before reading them back.
Stores
Every bundled store supports it:MemoryStore (core), SQLiteStore and
RedisStore, PostgresStore, MysqlStore and MongoStore. See
Persistent store for the tables and keys they
use.
A custom IStore without the optional user-data methods makes ctx.user
reject with UserDataError('UNSUPPORTED_STORE'); subject export and
deletion keep working (userData: [] / 0). supportsUserData(store)
(from @botmesh/core/spi) tells you which case you are in. To implement it,
add getUserData, setUserData, deleteUserData, listUserData and
purgeExpiredUserData; the botmesh repository checks every bundled store
with the same shared suite (test-utils/user-data-conformance.ts).
Privacy: export and erasure
Errors
UserDataError has a code: INVALID_PATH, INVALID_NAMESPACE,
INVALID_VALUE, INVALID_TTL, INVALID_USER, UNSUPPORTED_STORE or
UNAVAILABLE.