Reference
Session stores
The SessionStore contract, the four bundled implementations and how each enforces compare-and-swap, the conflict error, and the conformance suite a custom store must pass.
A session store is the only durable dependency PicoFlow has. It persists whole session documents and enforces optimistic concurrency; it does not interpret flows, steps, or restore policy.
The contract
export interface SessionStore {
load(sessionId: string): Promise<SessionType | null>;
create(flow: FlowType): Promise<SessionType>;
save(sessionDoc: SessionType, expectedRevision: number): Promise<SessionType>;
delete(sessionId: string, expectedRevision?: number): Promise<void>;
close(): Promise<void>;
}
| Method | Required behaviour |
|---|---|
load |
Return the stored document without applying restoration policy. A Flow’s idle-time or other restore decision belongs in onRestoreSessionDoc(), not the store |
create |
Build a document through FlowCreator.createDoc(flow) and persist it at revision 0 |
save |
Persist only if the stored revision equals expectedRevision, then return the document at expectedRevision + 1 |
delete |
Compare-and-swap when a revision is supplied; unconditional when it is omitted |
close |
Release connections and handles |
FlowSession wraps the store: it asserts the one-flow invariant, stamps saveOn, passes the
document’s current revision as the expected value, and copies the returned revision back onto
the in-memory document so later checkpoints in the same run use a fresh token.
Choosing a store
The store is selected by the SESSION_STORE environment variable, uppercased, and defaults to
MEMORY. An unrecognised value throws
No valid session store '<value>'. Use MEMORY, MONGO, COSMO, or SQLITE.
Tests and dependency injection can bypass configuration entirely:
const engine = await FlowEngine.create({
sessionStore: new MemorySessionStore(),
flows: [CustomerFlow],
});
The four bundled stores
SESSION_STORE |
Class | Compare-and-swap mechanism | Configuration |
|---|---|---|---|
MEMORY |
MemorySessionStore |
Compares the current in-memory revision before replacing a structured clone | None |
SQLITE |
SQLiteSession |
Atomic UPDATE … WHERE id = ? AND revision = ?; a row count other than 1 is a conflict |
SQLITE_PATH |
MONGO |
MongoSession |
Update filter combining _id, the flow name, and the expected revision; matchedCount other than 1 is a conflict |
MONGODB_URL, MONGODB_NAME, MONGODB_COLLECTION |
COSMO or COSMOS |
CosmoSession |
Expected revision plus an _etag IfMatch precondition |
COSMODB_URL, COSMODB_KEY, COSMODB_ID, COSMODB_SESSION_ID |
SQLiteSession writes to a table named session, creates the parent directory if it is
missing, and defaults to ignore/session/session.sqlite. The revision column is the
compare-and-swap source of truth; older JSON-only rows are migrated by an ALTER TABLE on
open. Mongo’s filter includes the flow name, so a store-level attempt to change the flow bound
to a session ID also fails.
Deployment scope
| Store | Use it for |
|---|---|
| Memory | Examples, unit tests, and single-process local development. It does not survive a restart and cannot coordinate two processes |
| SQLite | Local durable development, and deployments where a shared filesystem is acceptable. Safe across connections and processes on one host |
| MongoDB | Horizontally scaled deployments sharing one database |
| Cosmos DB | Horizontally scaled deployments on Azure |
The FlowEngine session lock is process-local, and the Memory store's revision check is process-local too. Two application instances backed by the Memory store share nothing at all — each holds its own session map, so a session ID created on one instance simply does not exist on the other.
SessionConflictError
export class SessionConflictError extends PicoFlowError {
constructor(sessionId: string);
}
| Property | Value |
|---|---|
code |
SESSION_CONFLICT |
statusCode |
409 |
name |
SessionConflictError |
message |
Session '<id>' was changed or removed by another request. |
Thrown by a stale save, and by a delete that supplied a revision which no longer matches.
FlowEngine returns a failure envelope for it and deliberately does not mark the stored
session aborted — the winning document must remain untouched.
The other errors in this family:
| Class | Code | Status | Raised when |
|---|---|---|---|
SessionFlowMismatchError |
SESSION_FLOW_MISMATCH |
409 | A session ID is reused with a different flowName |
SessionFlowInvariantError |
SESSION_FLOW_INVARIANT |
409 | The document is not the singular one-flow shape, or its flow name is blank |
SessionStoreError |
SESSION_STORE_ERROR |
500 | A backend operation failed; carries operation and sessionId |
All four extend PicoFlowError, so isSessionStoreError(error) recognises them.
Handling a conflict
There is no automatic replay. Blind retry is unsafe, because the losing attempt may already have called a model, sent a message, uploaded a file, or charged a card before it lost the save. Handle a conflict by returning a retryable error to the caller, reloading the latest state, deciding whether the original command is still valid, and retrying only through an idempotent path. See Concurrency and session conflicts.
Custom store conformance
import { SessionStoreConformanceUtil } from "@picoflow/core";
await SessionStoreConformanceUtil.run(() => new MyStore());
run(createStore) exercises only the public contract, so the same suite applies to every
backend. It asserts that:
createreturnsrevision0 andloadreturns the stored revision;saveincrements the revision;- ISO-looking strings inside user data survive a round trip unchanged;
- a stale
saveand a stale revision-checkeddeleteboth throwSessionConflictError; deleteremoves the document, after whichloadreturnsnull;- two concurrent saves from one revision produce exactly one winner, and the loser throws
SessionConflictError; loadreturns a document that a Flow could consider stale, because restore policy is not the store’s job; and- saving a document whose
flow.nameis blank throwsSessionFlowInvariantError.
A store that passes is safe to register through FlowEngine.create({ sessionStore }). Run it in
CI against real infrastructure — the race check in step 6 is the one that matters most and the
one an in-process fake will not exercise honestly.
See Persistence and session stores for operational guidance and Environment variables for the full configuration list.