picoflow.io Docs

Guides

Persistence and session stores

Initialize database clients in your application, choose a session store, and manage its lifecycle. Memory is the default and it does not survive a restart.

Every PicoFlow conversation is one JSON document. Choosing where that document lives is a deployment decision you should make before the first real user, because the default is process-local memory and loses everything on restart.

Persistence is also your operational baseline

A durable store retains the active cursor, state, memories, tokens, status, and structured diagnostics together. The store does not create dashboards or alerts for you, but its common document shape makes internal inspection and document-database aggregation straightforward. See Operate and debug session documents for the safe incident workflow.

Choosing a store

Store SESSION_STORE Durable Safe across processes Use for
Memory MEMORY (default) No No Examples, unit tests, throwaway local runs
SQLite SQLITE Yes Yes, over a shared file Local development, single-node deployments
MongoDB MONGO or MONGODB Yes Yes Horizontally scaled deployments
Cosmos DB COSMO, COSMOS, or COSMOSDB Yes Yes Azure deployments

All four implement the same contract, including revision-based compare-and-swap. The difference is where the atomic check happens — see Concurrency and session conflicts.

An unrecognised value fails fast at startup:

No valid session store 'POSTGRES'. Use MEMORY, MONGO, COSMO, or SQLITE.

Application-owned database initialization

Initialize MongoDB and Cosmos SDK clients in your application’s startup code, alongside flows and model providers. In pico-demo’s AppModule, FlowEngine.create() receives configManager: config and inline sessionClients factories. The application reads the URL, credentials, and SDK options; PicoFlow supplies the session-store adapter and persistence contract.

Install the SDKs your application imports:

npm install mongodb @azure/cosmos @azure/identity

This Nest example follows the demo’s convention, with three tutorial flows and their model providers. Add the other flow registrations your application uses.

import { Inject, Module, type OnApplicationShutdown } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { MongoClient } from "mongodb";
import { CosmosClient } from "@azure/cosmos";
import { ClientSecretCredential, DefaultAzureCredential } from "@azure/identity";
import { FlowEngine, ModelProvider } from "@picoflow/core";
import { BasicFlow } from "./myflow/basic-flow/basic-flow.js";
import { HotelFlow } from "./myflow/hotel-flow/hotel-flow.js";
import { InvoiceFlow } from "./myflow/invoice-flow/invoice-flow.js";

@Module({
  imports: [ConfigModule.forRoot()],
  providers: [{
    provide: FlowEngine,
    inject: [ConfigService],
    useFactory: (config: ConfigService) => FlowEngine.create({
      configManager: config,
      flows: [BasicFlow, HotelFlow, InvoiceFlow],
      providers: ModelProvider.createBuiltinAdapters({
        openai: { apiKey: config.get<string>("OPENAI_API_KEY") },
        google: { apiKey: config.get<string>("GEMINI_API_KEY") },
      }),
      sessionClients: {
        mongodb: () => {
          const url = config.getOrThrow<string>("MONGODB_URL");
          const tlsCAFile = config.get<string>("MONGODB_TLS_CA_FILE");
          return new MongoClient(url, tlsCAFile ? { tlsCAFile } : {});
        },
        cosmos: () => {
          const endpoint = config.get<string>("COSMODB_URL")
            || config.get<string>("COSMO_ENDPOINT")
            || config.get<string>("COSMOS_ENDPOINT");
          if (!endpoint) throw new Error("Cosmos endpoint is required.");
          const key = config.get<string>("COSMODB_KEY")
            || config.get<string>("COSMOS_KEY");
          if (key) return new CosmosClient({ endpoint, key });

          const tenantId = config.get<string>("AZURE_TENANT_ID");
          const clientId = config.get<string>("COSMO_DB_CLIENT_ID");
          const clientSecret = config.get<string>("COSMO_DB_CLIENT_SECRET");
          if (clientId || clientSecret) {
            if (!tenantId || !clientId || !clientSecret) {
              throw new Error("Cosmos service principal requires tenant, client ID, and secret.");
            }
            return new CosmosClient({
              endpoint,
              aadCredentials: new ClientSecretCredential(tenantId, clientId, clientSecret),
            });
          }
          return new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() });
        },
      },
    }),
  }],
})
export class AppModule implements OnApplicationShutdown {
  constructor(@Inject(FlowEngine) private readonly engine: FlowEngine) {}

  async onApplicationShutdown(): Promise<void> {
    await this.engine.close();
  }
}

SESSION_STORE selects the backend; only that backend’s factory runs. With SESSION_STORE=COSMO, the engine does not construct a MongoDB client or require MONGODB_URL. Factories take no arguments and can return a client or a promise. Read backend-specific settings inside each factory so unused credentials are not required during startup.

The Cosmos factory uses a key first. To use an explicit service principal, omit the key and set AZURE_TENANT_ID, COSMO_DB_CLIENT_ID, and COSMO_DB_CLIENT_SECRET. With neither a key nor explicit client credentials, this application uses DefaultAzureCredential. Choose other SDK authentication and connection options here when your deployment needs them.

MONGODB_TLS_CA_FILE is optional: it supplies the MongoDB SDK’s tlsCAFile option when the connection needs a custom CA certificate file. Keep TLS and DocumentDB-specific options in the application factory.

PicoFlow connects the MongoDB client before returning the engine. For Cosmos, it initializes the configured database/container and checks that the partition key is /id. Factories are optional; without one for the selected backend, the framework retains its built-in configuration-based client creation. That Cosmos fallback uses key authentication; the factory above owns the additional Azure credential choices.

Configuration

SESSION_STORE=SQLITE
SQLITE_PATH=ignore/session/session.sqlite   # default when unset

# SESSION_STORE=MONGO
MONGODB_URL=mongodb://localhost:27017/?directConnection=true   # required
MONGODB_NAME=picoflow
MONGODB_COLLECTION=sessions

# SESSION_STORE=COSMO
COSMODB_URL=https://your-account.documents.azure.com:443/
COSMODB_KEY=your-account-key
COSMODB_ID=picoflow
COSMODB_SESSION_ID=sessions
COSMOS_CREATE_IF_NOT_EXISTS=false  # for resources provisioned separately

MONGODB_NAME and MONGODB_COLLECTION are required even when your factory supplies a MongoDB client. Cosmos requires a database ID and container ID; their aliases are listed in Environment variables. COSMOS_CREATE_IF_NOT_EXISTS defaults to true, which permits PicoFlow to create the Cosmos database and container if needed. Set it to false for pre-provisioned resources and grant the identity the permissions required for session operations.

SESSION_STORE is the selector; the legacy DOCUMENT_DB entry in the demo’s sample file has no runtime effect. The current sample sets SESSION_STORE=SQLITE.

Pass the same configuration reader to the engine and your SDK factories. Nest’s ConfigService is supported directly. Standalone applications can use ConfigManager, whose precedence is explicit values, then the environment, then a dotenv file. The selected store and existing SDK clients are established when FlowEngine.create() runs; changing settings later does not switch them.

Client ownership and shutdown

Clients returned by sessionClients are owned by the engine by default. await engine.close() closes MongoDB or disposes Cosmos, and owned resources are released if engine initialization fails. The demo calls it from AppModule.onApplicationShutdown() and enables Nest shutdown hooks in main.ts with app.enableShutdownHooks().

For an application-managed shared client, set sessionClients.ownsClients: false. The engine then leaves that SDK client open; your application closes it when all its consumers are done. A standalone service should call engine.close() through its own shutdown lifecycle.

What the document contains

Session document
├── id                    uuid, returned as CHAT_SESSION_ID
├── revision              integer compare-and-swap token, incremented on every write
├── version               session schema version (K.sessionDocVersion)
├── runStatus             "running" | "completed" | "aborted"
├── createdOn, saveOn     Date
├── tokens                input/output/total plus reasoning, visible, cached breakdowns
├── log, error, warn, debug, verbose    structured SessionLogger entries
└── flow                  exactly one envelope — never an array
    ├── name              the registered flow name, permanently bound to this ID
    ├── model             { provider, name, params, retryAttempts? } with credential keys stripped
    ├── context           the first request's config
    ├── memory            namespace -> { messages, summary?, summarizedThroughId? }
    ├── steps             [{ name, state, model? }]
    ├── currentStep       the one durable cursor, or null
    └── sequence          [{ level, stepName }] execution trace

revision and version are unrelated. revision guards writes; version describes the schema and drives migration.

Only metadata dates are revived

Stores hydrate createdOn and saveOn back into Date objects. Anything date-shaped inside step state, memory or context is deliberately left as the string you stored. Do not assume getState("dueDate") returns a Date after a restore.

The store 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>;
}

load() applies no policy. It does not check a Flow’s idle rule, run status or schema version — those decisions belong to Flow.onRestoreSessionDoc(). save() must reject a stale revision with SessionConflictError.

Inject an alternative implementation through the engine, which is also how tests substitute a store:

FlowEngine.create({
  flows: [CustomerFlow],
  providers: [...],
  sessionStore: new MyStore(),
});

Then verify it against the shared behavioural suite:

await SessionStoreConformanceUtil.run(() => new MyStore());

The suite covers revision numbering, conflict on a stale save, conflict on a stale delete, preservation of ISO-looking strings in user data, and the requirement that two concurrent saves from one revision produce exactly one winner.

Flow-owned session idle policy

The store does not evaluate session age. A Flow that needs an idle rule calls sessionIdleMs(doc) from onRestoreSessionDoc() and returns null to start a new session. Retention and cleanup remain separate application responsibilities.

Completion versus deletion

These are different operations with different consequences.

Operation What it does Document Session ID
TerminateSessionStep Sets runStatus = "completed" in onEnter(), reports isEnd() Retained Cannot be resumed; a new request with it creates a new session
sessionCompleted() / markCompleted() Sets runStatus = "completed" directly Retained Same
Unhandled error Engine sets runStatus = "aborted" and persists the message Retained Same
deleteSession(id) Removes the row or document, under the same lock and revision check as a write Gone Gone
// Completing a conversation
return go(TerminateSessionStep).withPrompt("Confirm the saved customer.");

// Deleting the record
await flowEngine.deleteSession(sessionId);

A completed session is a record you can audit: transcript, state, token totals, log entries. Delete only when the record itself must not exist — a retention policy, a privacy request.

deleteSession() returns { success, session } and, on failure, a message. Note that it is not a RunResponseType; it has no completed or contentType.

endChat() is deprecated

FlowEngine.endChat(sessionId) now simply delegates to deleteSession(). The name is misleading: ending a conversation does not imply destroying its record. Call deleteSession() directly, and migrate any POST /ai/end style endpoint to an HTTP DELETE route so the API stops conflating completion with deletion.

Failure modes

Symptom Cause
Sessions vanish after a restart SESSION_STORE unset or set to MEMORY
Sessions vanish after a deploy, with a durable store DOCUMENT_DB was set instead of SESSION_STORE
Conversations restart mid-way A Flow-owned restore policy returned null; inspect its idle or validation rule
Conversations restart after a release onRestoreSessionDoc() reset them on a schema version bump
Configuration value 'MONGODB_URL' is required. Mongo selected without a connection string
SessionConflictError on save Another writer advanced the revision first
Session 'x' belongs to flow 'A', not 'B'. A session ID was reused with a different flowName
Session 'x' violates the one-flow invariant The document was hand-edited or written by something other than PicoFlow
Dates come back as strings Only session metadata dates are revived

Related: The session document, Concurrency and session conflicts, and Session stores.