typescript

Store And Migration

Store And Migration.ts
/**
 * Persistence: load, turn, save, and the one-time move of a 3.x session.
 *
 * Teaches: `Store`, `MemoryStore`, `save(session, expectedVersion)`,
 * `SessionConflictError`, `migrateSession`, `InvalidSessionError`.
 * Read next: docs/guides/persistence.md
 *
 * Run: GEMINI_API_KEY=... bun run examples/08-store-and-migration.ts
 */

import {
  falai,
  GeminiProvider,
  InvalidSessionError,
  MemoryStore,
  migrateSession,
  SessionConflictError,
  type DataOf,
  type Session,
  type TurnResult,
} from "@falai/agent";

const f = falai().fields({
  nome: { type: "string", ask: "Pergunte o nome." },
});
type Data = DataOf<typeof f>;

const agent = f.agent({
  name: "Ana",
  provider: new GeminiProvider({ apiKey: process.env.GEMINI_API_KEY ?? "", model: "gemini-2.5-flash" }),
  flows: [
    f.flow({
      id: "boas-vindas",
      name: "Boas-vindas",
      on: [{ message: [] }],
      steps: [{ id: "nome", collect: ["nome"] }],
    }),
  ],
});

// Any of the seven stores works here; all have the same two methods.
// PostgresStore, PrismaStore, RedisStore, MongoStore, SQLiteStore, OpenSearchStore take a client.
const store = new MemoryStore<Data>();

// ─── The host's turn: load → turn → save ────────────────────────────────────
// The framework never saves. You save when the turn changed something, with
// the version you loaded, so two concurrent turns cannot both win.

async function runTurn(sessionId: string, message: string): Promise<TurnResult<Data>> {
  const session = (await store.load(sessionId)) ?? undefined;
  const result = await agent.turn({ sessionId, session, message });
  if (!result.changed) return result;
  try {
    // 0 means "insert if absent". Later saves pass the loaded version.
    await store.save(result.session, session?.version ?? 0);
  } catch (error) {
    if (error instanceof SessionConflictError) {
      // Someone saved first. Nothing was sent yet, so just play the same input again.
      return runTurn(sessionId, message);
    }
    throw error;
  }
  // Only now: send result.messages, enqueue result.schedule.
  return result;
}

const r = await runTurn("demo", "oi, sou a Bia");
console.log(r.messages[0]?.text, r.session.data); // ... { nome: 'Bia' }

// ─── Moving a 3.x session ───────────────────────────────────────────────────
// A 3.x row holds `currentFlow`, `currentStep`, `signals.triggers`. Migrate it
// once, where you deserialize, then save it into the new table. The migrated
// session has version 0, so the usual save is the insert.

const legacyRow: unknown = {
  id: "conv-42",
  version: 12,
  data: { nome: "Rui" },
  currentFlow: { id: "boas-vindas", title: "Boas-vindas" },
  currentStep: { id: "nome" },
  signals: { triggers: { pediu_humano: { firedAt: "2026-09-01T13:05:12.000Z" } } },
};

const migrated: Session<Data> = migrateSession<Data>(legacyRow, {
  sessionId: "conv-42",
  // Signal keys and old flow ids → v4 flow ids. Identity when you kept the ids.
  flowIdOf: (key) => key,
});
console.log(migrated.runs[0]?.stepId, Object.keys(migrated.claims)); // 'nome' [ 'pediu_humano:conv-42:', 'boas-vindas:conv-42:' ]
await store.save(migrated, migrated.version);

// A row that is neither v4 nor 3.x is an error, never a fresh conversation.
try {
  migrateSession("garbage", { sessionId: "conv-43", flowIdOf: (key) => key });
} catch (error) {
  if (error instanceof InvalidSessionError) console.log(error.message);
}