---
title: '0.22'
description: Event ids are unique per session, not per store, and a presence clear keeps its stamp.
---

## Event ids are unique per session

The same event id in two sessions is now two events. Before, appending an id
that another session already used threw `PARTIAL_DUPLICATE_BATCH`. Inside one
session nothing changes: an identical retry returns the original rows, a batch
that mixes sent and fresh events throws, and a consumer's retried completion
returns the events it committed.

- **Ids you derive need only the session's scope.** An
  [`idempotentId()`](/reference/core#idempotentidparts) recipe that includes
  the session still works; one that leaves it out no longer collides with
  another session's events.
- **Idempotency keys built from ids.** `event.id` and an actor's `ctx.id` are
  unique within their session. Ids your callers pick, and the ids A2 derives
  from them, can now repeat in another session, so a key for an external side
  effect built from one alone can too. When yours can repeat, add the session
  to the key; see
  [Consumers can run twice](/server/consumers#consumers-can-run-twice).
- **Clear markers** follow the same rule.
- **Custom stores** check ids against the session's own rows only: the
  idempotent-replay and partial-batch checks in `append` and
  `completeAttempt` ignore other sessions. See
  [`A2Store`](/reference/server#a2store).

## Postgres

0.22's `schema.sql` replaces the unique index on `a2_events (event_id)`,
`a2_events_event_id`, with one on `a2_events (session_id, event_id)`,
`a2_events_session_event_id`. Every row is kept, and building the new index
can't fail: the old one already keeps those pairs unique.

1. **Apply 0.22's `schema.sql`**, as the role your app connects with:
   `node_modules/experimental-a2/sql/postgres/schema.sql`.
2. **Deploy 0.22.**

The first time it runs, the swap locks `a2_events` against every read and write
until the new index is built, then drops the old one. On a large table, make
both changes by hand instead, with nothing blocked. Run each statement on its
own, outside a transaction:

```sql
create unique index concurrently if not exists a2_events_session_event_id
  on a2_events (session_id, event_id);
```

A concurrent build that fails leaves an invalid index of the same name behind.
Check that this returns `true` before you drop the old index; if it doesn't,
drop the new one and build it again:

```sql
select indisvalid from pg_index
 where indexrelid = 'a2_events_session_event_id'::regclass;
```

```sql
drop index concurrently if exists a2_events_event_id;
```

Then apply `schema.sql`: it finds both done and waits on nothing. It drops the
old index only once the new one is valid.

With Drizzle, update your copy of `drizzle-schema.ts`:
`a2_events_session_event_id` on `(session_id, event_id)` replaces
`a2_events_event_id`. On a large table, run the statements above by hand
instead, and take them out of the migration Drizzle generates.

## SQLite

Nothing to do. The store creates the new index and drops the old one when it
opens the file. Once 0.22 has opened a file, don't open it with an earlier
release: that release builds the old index again, and can't open the file at
all once two sessions share an id.

## Redis

Redis names every key differently in this release, so it starts fresh:
**point the store at a new `keyPrefix`**, then deploy. Under its new prefix,
0.22 never reads what an earlier release wrote; delete the old prefix's keys
whenever convenient. On a prefix 0.21 wrote, 0.22 refuses to start with
`STORE_NOT_CONFIGURED`. A `keyPrefix` can't contain braces.

What changes:

- **Scripts lock only their session on Upstash.** Every key of a session
  shares one hash tag, so each script sets Upstash's `allow-key-locking` and
  locks its session's keys instead of the whole database. The store asks the
  server once whether it accepts the flag; Redis and Valkey don't, and run one
  command at a time anyway.
- **No script publishes.** Each write publishes its wake-up after its script,
  so a slow subscriber never holds a lock.
- **`clear()` works on Upstash.** It used to rename the log aside, which
  Upstash refuses for streams. The marker now joins the same log, and the old
  lifetime's entries stay below it, unread, until `clear()` finishes deleting
  them. A session that stays empty after its clear keeps only its clear marker,
  the log holding it, and its log consumers' positions.

## Memory

Nothing to do: the memory store starts empty in every process.

## Presence clears keep their stamp

A `null` from `setPresence()` now leaves a tombstone in the store, carrying the
clear's `at`, until the field's TTL runs out. An older value that reaches the
store after the clear, such as one sent through another server instance, still
loses. Before, it brought the field back. Nothing to do: Postgres and SQLite
keep tombstones in `a2_presence` as a JSON `null` value.

Custom stores with presence keep a tombstone for each `null`, compare it by
`at` like a value until it expires, and leave tombstones out of `read`.
