0.22

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() 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.
  • 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.

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:

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:

select indisvalid from pg_index
 where indexrelid = 'a2_events_session_event_id'::regclass;
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.