@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
802 lines (739 loc) • 29.9 kB
text/typescript
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GenkitError } from '@genkit-ai/core';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import {
assertValidSessionId,
type GetSnapshotOptions,
type SessionSnapshot,
type SessionStore,
type SessionStoreOptions,
type SnapshotMutator,
} from './session.js';
/**
* Normalizes and validates {@link GetSnapshotOptions}.
*
* Enforces that exactly one of `snapshotId` / `sessionId` is provided and,
* when a `sessionId` is given, that it is a valid UUID.
*
* @throws `INVALID_ARGUMENT` when neither or both are provided.
*/
function normalizeGetSnapshotOptions(opts: GetSnapshotOptions): {
snapshotId?: string;
sessionId?: string;
} {
const { snapshotId, sessionId } = opts;
if (!!snapshotId === !!sessionId) {
throw new GenkitError({
status: 'INVALID_ARGUMENT',
message:
`getSnapshot requires exactly one of 'snapshotId' or 'sessionId' ` +
`(got ${snapshotId ? 'snapshotId' : 'neither'}${sessionId ? ' and sessionId' : ''}).`,
});
}
if (sessionId) {
assertValidSessionId(sessionId);
}
return { snapshotId, sessionId };
}
/**
* Selects the latest leaf snapshot from a set belonging to one session.
*
* A "leaf" is a snapshot that no other snapshot points to as its `parentId`.
* A healthy linear session has exactly one leaf - the latest turn.
*
* The leaf is returned regardless of `status`; resumability (only `done`
* snapshots are resumable) is enforced by the agent, which walks back over a
* non-resumable leaf (e.g. a failed turn) to the last-good snapshot.
*
* - Returns `undefined` when `snapshots` is empty.
* - Returns the single leaf when the history is linear.
* - When the history has branched (more than one leaf, e.g. after a
* regenerate) the behavior depends on `rejectBranching`:
* - `false` (default): returns the most-recently created leaf (by
* `createdAt`). This keeps `sessionId` lookups cheap and forgiving.
* - `true`: throws `FAILED_PRECONDITION`, since there is no unambiguous
* "latest". Opt into this in dev to surface accidental branching early.
*/
function selectLeafSnapshot<S>(
snapshots: SessionSnapshot<S>[],
sessionId: string,
rejectBranching = false
): SessionSnapshot<S> | undefined {
if (snapshots.length === 0) return undefined;
const parentIds = new Set<string>();
for (const snap of snapshots) {
if (snap.parentId) parentIds.add(snap.parentId);
}
const leaves = snapshots.filter((s) => !parentIds.has(s.snapshotId));
// A single-snapshot session, or any chain, collapses to one leaf.
if (leaves.length === 1) return leaves[0];
if (leaves.length === 0) {
// Cyclic / corrupt history - every snapshot is someone's parent.
throw new GenkitError({
status: 'FAILED_PRECONDITION',
message:
`Session '${sessionId}' has no leaf snapshot (corrupt or cyclic ` +
`history). Resume by snapshotId instead.`,
});
}
if (rejectBranching) {
throw new GenkitError({
status: 'FAILED_PRECONDITION',
message:
`Session '${sessionId}' has branching snapshots (${leaves.length} ` +
`leaves), so there is no single latest snapshot. This happens when a ` +
`conversation is branched (e.g. regenerate). Resume by snapshotId instead.`,
});
}
// Default: pick the most-recently created leaf. `createdAt` is an ISO-8601
// timestamp, so lexicographic comparison matches chronological order.
return leaves.reduce((latest, snap) =>
snap.createdAt > latest.createdAt ? snap : latest
);
}
/**
* In-memory implementation of persistent Session Store.
*/
export class InMemorySessionStore<S = unknown> implements SessionStore<S> {
private snapshots = new Map<string, SessionSnapshot<S>>();
private listeners = new Map<
string,
Array<(snapshot: SessionSnapshot<S>) => void>
>();
private rejectBranchingSessions: boolean;
/**
* @param options.rejectBranchingSessions When `true`, a `sessionId` lookup
* that resolves to a branched history (more than one leaf) throws
* `FAILED_PRECONDITION` instead of returning the latest leaf. Defaults to
* `false`; opt in (e.g. in dev) to surface accidental branching early.
*/
constructor(options?: { rejectBranchingSessions?: boolean }) {
this.rejectBranchingSessions = options?.rejectBranchingSessions ?? false;
}
async getSnapshot(
opts: GetSnapshotOptions
): Promise<SessionSnapshot<S> | undefined> {
const { snapshotId, sessionId } = normalizeGetSnapshotOptions(opts);
if (snapshotId) {
const snap = this.snapshots.get(snapshotId);
if (!snap) return undefined;
return structuredClone(snap);
}
// sessionId lookup: gather every snapshot belonging to this session and
// resolve the single leaf (latest) snapshot.
const owned: SessionSnapshot<S>[] = [];
for (const snap of this.snapshots.values()) {
if ((snap.sessionId ?? snap.state?.sessionId) === sessionId) {
owned.push(snap);
}
}
const leaf = selectLeafSnapshot(
owned,
sessionId!,
this.rejectBranchingSessions
);
return leaf ? structuredClone(leaf) : undefined;
}
async saveSnapshot(
snapshotId: string | undefined,
mutator: SnapshotMutator<S>,
options?: SessionStoreOptions
): Promise<string | null> {
const current = snapshotId ? this.snapshots.get(snapshotId) : undefined;
const result = mutator(current ? structuredClone(current) : undefined);
if (result === null) return null;
// Determine the final ID. The runtime normally supplies a snapshotId, but
// fall back to a fresh UUID for direct store users who omit it.
const id =
snapshotId || result.snapshotId || globalThis.crypto.randomUUID();
const full: SessionSnapshot<S> = {
...result,
snapshotId: id,
};
this.snapshots.set(id, structuredClone(full));
const snapshotListeners = this.listeners.get(id);
if (snapshotListeners) {
for (const listener of snapshotListeners) {
listener(structuredClone(full));
}
}
return id;
}
onSnapshotStateChange(
snapshotId: string,
callback: (snapshot: SessionSnapshot<S>) => void,
options?: SessionStoreOptions
): void | (() => void) {
if (!this.listeners.has(snapshotId)) {
this.listeners.set(snapshotId, []);
}
this.listeners.get(snapshotId)!.push(callback);
return () => {
const list = this.listeners.get(snapshotId);
if (list) {
const index = list.indexOf(callback);
if (index >= 0) list.splice(index, 1);
}
};
}
}
/**
* Default interval (ms) for the polling fallback used by
* {@link FileSessionStore.onSnapshotStateChange}.
*/
const DEFAULT_SNAPSHOT_WATCH_POLL_INTERVAL_MS = 2000;
/**
* Validates that an id is a plain file basename and not a path that could
* escape the (per-tenant) prefix directory.
*
* Ids (`snapshotId`, `sessionId`) can arrive straight off the wire (the
* abort/getSnapshot actions accept a bare string), so without this an id like
* `../../foo` would let a caller read or write outside the prefix and break
* per-tenant isolation.
*/
function assertSafeId(id: string, label: string): void {
if (
!id ||
id.includes('/') ||
id.includes('\\') ||
id.includes('\0') ||
id === '.' ||
id === '..' ||
path.basename(id) !== id
) {
throw new GenkitError({
status: 'INVALID_ARGUMENT',
message: `Invalid ${label}: "${id}". A ${label} must be a plain file name (no path separators or "..").`,
});
}
}
/**
* Validates that a snapshotId is a plain file basename and not a path that
* could escape the (per-tenant) prefix directory.
*/
function assertSafeSnapshotId(snapshotId: string): void {
assertSafeId(snapshotId, 'snapshotId');
}
/**
* The per-session pointer file. Records the current leaf snapshot id for a
* session so a `getSnapshot({ sessionId })` lookup is a single pointer read
* followed by one snapshot read, instead of scanning and parsing every snapshot
* file in the prefix directory.
*/
interface PointerDoc {
currentSnapshotId: string;
updatedAt: string;
}
/**
* Hidden sub-directory (within each prefix) holding the per-session
* {@link PointerDoc} files. It is a directory, so the snapshot scan in
* {@link FileSessionStore.getLatestSnapshotForSession} - which only considers
* `*.json` *files* - naturally skips it.
*/
const POINTERS_SUBDIR = '.pointers';
/**
* A Node.js file-system backed session snapshot store.
*
* Snapshots are stored as flat JSON files keyed by their `snapshotId`, under an
* optional per-tenant sub-directory `prefix`:
*
* File layout: `dirPath/<prefix>/<snapshotId>.json`
*
* `getSnapshot({ sessionId })` resolves the session's current leaf via a tiny
* per-session pointer file (`<prefix>/.pointers/<sessionId>.json`, see
* {@link PointerDoc}) - one pointer read plus one snapshot read. When the
* pointer is missing (e.g. a legacy store) or stale it transparently falls back
* to scanning the prefix directory and selecting the single leaf whose
* `sessionId` matches, then rewrites the pointer so subsequent lookups are fast
* again.
*/
export class FileSessionStore<S = unknown> implements SessionStore<S> {
private dirPath: string;
private maxPersistedChainLength?: number;
private snapshotPathPrefix?: (options?: SessionStoreOptions) => string;
private rejectBranchingSessions: boolean;
private snapshotWatchPollIntervalMs: number;
/**
* Per-file write locks. The {@link SessionStore} contract (and the
* abort-aware mutator that branches on `current.status`) assumes
* read-modify-write is atomic, but on the file system a read and the
* `writeFile` below it are not. Without a lock two concurrent saves can
* read the same `current` and the later write clobbers the earlier one
* (e.g. a `completed` write overwriting a concurrent `aborted`). We
* serialize saves per resolved file path with a simple promise chain.
*/
private writeLocks = new Map<string, Promise<unknown>>();
/**
* @param dirPath Directory where snapshot JSON files are stored.
* @param options.maxPersistedChainLength When set, snapshots older than this
* many entries in a chain are automatically deleted on each save.
* @param options.snapshotPathPrefix Returns a sub-directory prefix derived
* from the call's {@link SessionStoreOptions} (e.g. the authenticated user
* id from `options.context`), useful for multi-tenant isolation: all reads
* and writes are scoped to that prefix, so one tenant can never see
* another's snapshots. Defaults to `"global"`.
* @param options.rejectBranchingSessions When `true`, a `sessionId` lookup
* that resolves to a branched history (more than one leaf) throws
* `FAILED_PRECONDITION` instead of returning the latest leaf. Defaults to
* `false`; opt in (e.g. in dev) to surface accidental branching early.
* @param options.snapshotWatchPollIntervalMs Polling interval (ms) for the
* {@link FileSessionStore.onSnapshotStateChange} fallback that backstops
* `fs.watch` (which can miss events on some filesystems, e.g. network
* mounts). Defaults to {@link DEFAULT_SNAPSHOT_WATCH_POLL_INTERVAL_MS}.
*/
constructor(
dirPath: string,
options?: {
maxPersistedChainLength?: number;
snapshotPathPrefix?: (options?: SessionStoreOptions) => string;
rejectBranchingSessions?: boolean;
snapshotWatchPollIntervalMs?: number;
}
) {
this.dirPath = path.resolve(dirPath);
fs.mkdirSync(this.dirPath, { recursive: true });
this.maxPersistedChainLength = options?.maxPersistedChainLength;
this.snapshotPathPrefix = options?.snapshotPathPrefix;
this.rejectBranchingSessions = options?.rejectBranchingSessions ?? false;
this.snapshotWatchPollIntervalMs =
options?.snapshotWatchPollIntervalMs ??
DEFAULT_SNAPSHOT_WATCH_POLL_INTERVAL_MS;
}
private async ensureDir(dir: string): Promise<void> {
await fsp.mkdir(dir, { recursive: true });
}
/** Resolves the (per-tenant) directory snapshots are stored under. */
private prefixDir(options?: SessionStoreOptions): string {
const prefix = this.snapshotPathPrefix
? this.snapshotPathPrefix(options)
: 'global';
return path.join(this.dirPath, prefix);
}
/**
* Resolves the file path for a given snapshotId: `<prefix>/<snapshotId>.json`.
*/
private async getFilePath(
snapshotId: string,
options?: SessionStoreOptions
): Promise<string> {
assertSafeSnapshotId(snapshotId);
const dir = this.prefixDir(options);
await this.ensureDir(dir);
// Defense in depth: even after the basename check, confirm the resolved
// path stays inside the prefix directory.
const filePath = path.join(dir, `${snapshotId}.json`);
const resolvedDir = path.resolve(dir);
const resolved = path.resolve(filePath);
if (
resolved !== path.join(resolvedDir, `${snapshotId}.json`) ||
!resolved.startsWith(resolvedDir + path.sep)
) {
throw new GenkitError({
status: 'INVALID_ARGUMENT',
message: `Invalid snapshotId: "${snapshotId}". Resolved path escapes the snapshot directory.`,
});
}
return filePath;
}
/** Resolves the (per-tenant) directory holding per-session pointer files. */
private pointersDir(options?: SessionStoreOptions): string {
return path.join(this.prefixDir(options), POINTERS_SUBDIR);
}
/**
* Resolves the pointer file path for a session, validating `sessionId` is a
* plain basename so it can never escape the pointers directory. Pure: it does
* not create the directory, so the read path stays side-effect free. The
* write path calls {@link ensureDir} before writing.
*/
private getPointerPath(
sessionId: string,
options?: SessionStoreOptions
): string {
assertSafeId(sessionId, 'sessionId');
return path.join(this.pointersDir(options), `${sessionId}.json`);
}
/**
* Reads the per-session {@link PointerDoc}, or `undefined` when it is missing
* (legacy store / not yet written) or unreadable / corrupt - callers fall
* back to a full directory scan in that case. Best-effort: any IO/parse error
* resolves to `undefined` so the optimization can never make a lookup (or
* save) fail where the scan-only baseline would have succeeded. An invalid
* `sessionId` still throws (path validation is resolved outside the try) so it
* fails fast rather than silently being ignored.
*/
private async readPointer(
sessionId: string,
options?: SessionStoreOptions
): Promise<PointerDoc | undefined> {
const filePath = this.getPointerPath(sessionId, options);
let contents: string;
try {
contents = await fsp.readFile(filePath, 'utf-8');
} catch {
// Missing / unreadable pointer: fall back to the scan.
return undefined;
}
try {
const parsed = JSON.parse(contents) as PointerDoc;
// Guard the type too: a non-string id would later throw in `assertSafeId`
// on the fast path instead of falling back to the scan.
return typeof parsed?.currentSnapshotId === 'string' ? parsed : undefined;
} catch {
// Partially written / corrupt pointer: treat as absent and rescan.
return undefined;
}
}
/**
* Atomically writes the per-session {@link PointerDoc}. Best-effort: a
* pointer write failure is swallowed since the pointer is only an
* optimization - `sessionId` lookups still self-heal via the full scan. An
* invalid `sessionId` still throws (path validation is resolved outside the
* try) so it fails fast rather than silently being ignored.
*/
private async writePointer(
sessionId: string,
currentSnapshotId: string,
options?: SessionStoreOptions
): Promise<void> {
const filePath = this.getPointerPath(sessionId, options);
const dir = this.pointersDir(options);
try {
await this.ensureDir(dir);
const pointer: PointerDoc = {
currentSnapshotId,
updatedAt: new Date().toISOString(),
};
await this.atomicWrite(filePath, JSON.stringify(pointer, null, 2));
} catch {
// Ignore: the scan fallback keeps `sessionId` lookups correct.
}
}
/**
* Serializes async work per resolved file path so a read-modify-write in
* {@link saveSnapshot} is not interleaved with a concurrent one for the same
* snapshot (see {@link writeLocks}).
*/
private async withFileLock<T>(
filePath: string,
fn: () => Promise<T>
): Promise<T> {
const prev = this.writeLocks.get(filePath) ?? Promise.resolve();
// Wait for any in-flight save of this file, ignoring its result/error so a
// prior failure doesn't poison the lock for subsequent callers.
const gate = prev.then(
() => undefined,
() => undefined
);
this.writeLocks.set(filePath, gate);
await gate;
try {
return await fn();
} finally {
// If no one chained after us we are the tail; drop the entry to avoid
// leaking a map entry per snapshotId.
if (this.writeLocks.get(filePath) === gate) {
this.writeLocks.delete(filePath);
}
}
}
async getSnapshot(
opts: GetSnapshotOptions
): Promise<SessionSnapshot<S> | undefined> {
const { snapshotId, sessionId } = normalizeGetSnapshotOptions(opts);
if (sessionId) {
return this.getLatestSnapshotForSession(sessionId, opts);
}
const filePath = await this.getFilePath(snapshotId!, opts);
try {
const fileContents = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(fileContents) as SessionSnapshot<S>;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw e;
}
}
/**
* Loads a single snapshot file by its id (no sessionId branch). Used by
* internal traversal (parent chains) where we always have a concrete id.
*/
private async getSnapshotById(
snapshotId: string,
options?: SessionStoreOptions
): Promise<SessionSnapshot<S> | undefined> {
const filePath = await this.getFilePath(snapshotId, options);
try {
const fileContents = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(fileContents) as SessionSnapshot<S>;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw e;
}
}
/**
* Resolves the latest (leaf) snapshot for a session.
*
* Fast path: read the per-session pointer file and load the leaf it names -
* one pointer read plus one snapshot read, independent of session count /
* length. The pointer is skipped (and the scan used) when
* `rejectBranchingSessions` is set, since detecting branches requires seeing
* every leaf.
*
* Fallback (no/stale/corrupt pointer, or branch detection): scan every
* snapshot file in the prefix directory, keep those whose `sessionId`
* matches, select the single leaf, and refresh the pointer so later lookups
* take the fast path.
*
* Known limitation: the fast path trusts the pointer when the snapshot it
* names still exists and belongs to the session - it does not re-verify that
* it is the actual leaf. So if a save succeeds but the subsequent (best-effort)
* `writePointer` does not (crash/disk error), or two new saves for the same
* session race and the older one writes the pointer last, the pointer can
* linger on a valid-but-older same-session snapshot and lookups return it
* until the next save advances the pointer. This is the accepted trade-off for
* a best-effort cache: verifying leaf-ness on every read would reintroduce the
* full scan the pointer exists to avoid. Callers needing strict guarantees can
* resume by `snapshotId`, or set `rejectBranchingSessions` (which always
* scans).
*/
private async getLatestSnapshotForSession(
sessionId: string,
options?: SessionStoreOptions
): Promise<SessionSnapshot<S> | undefined> {
// Fast path via the pointer file (skipped when we must detect branching).
if (!this.rejectBranchingSessions) {
const pointer = await this.readPointer(sessionId, options);
if (pointer) {
const snap = await this.getSnapshotById(
pointer.currentSnapshotId,
options
);
// Honor the pointer only when the leaf still exists and belongs to this
// session; otherwise it is stale - fall through to the scan, which also
// rewrites the pointer.
if (snap && (snap.sessionId ?? snap.state?.sessionId) === sessionId) {
return snap;
}
}
}
const dir = this.prefixDir(options);
let files: string[];
try {
files = await fsp.readdir(dir);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw e;
}
const snapshots: SessionSnapshot<S>[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
try {
const contents = await fsp.readFile(path.join(dir, file), 'utf-8');
const snap = JSON.parse(contents) as SessionSnapshot<S>;
if ((snap.sessionId ?? snap.state?.sessionId) === sessionId) {
snapshots.push(snap);
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw e;
}
}
const leaf = selectLeafSnapshot(
snapshots,
sessionId,
this.rejectBranchingSessions
);
if (!leaf) return undefined;
// Refresh the pointer so subsequent lookups take the fast path.
// Best-effort.
await this.writePointer(sessionId, leaf.snapshotId, options);
return leaf;
}
async saveSnapshot(
snapshotId: string | undefined,
mutator: SnapshotMutator<S>,
options?: SessionStoreOptions
): Promise<string | null> {
// When an ID is supplied the read-modify-write below must be serialized
// against concurrent saves of the same snapshot, otherwise a later write
// (e.g. `completed`) can clobber an earlier concurrent one (e.g.
// `aborted`). New (UUID) snapshots have no contender, so skip the lock.
if (snapshotId) {
const lockPath = await this.getFilePath(snapshotId, options);
return this.withFileLock(lockPath, () =>
this.saveSnapshotUnlocked(snapshotId, mutator, options)
);
}
return this.saveSnapshotUnlocked(snapshotId, mutator, options);
}
private async saveSnapshotUnlocked(
snapshotId: string | undefined,
mutator: SnapshotMutator<S>,
options?: SessionStoreOptions
): Promise<string | null> {
// Read the current snapshot when an ID is provided.
const current = snapshotId
? await this.getSnapshotById(snapshotId, options)
: undefined;
const snapshot = mutator(current);
if (snapshot === null) return null;
// Determine the final ID. The runtime normally supplies a snapshotId, but
// fall back to a fresh UUID for direct store users who omit it.
const id =
snapshotId || snapshot.snapshotId || globalThis.crypto.randomUUID();
const full: SessionSnapshot<S> = {
...snapshot,
snapshotId: id,
};
const filePath = await this.getFilePath(id, options);
await this.atomicWrite(filePath, JSON.stringify(full, null, 2));
// Maintain the per-session pointer so `sessionId` lookups stay fast (one
// pointer read plus one snapshot read). Only a brand-new snapshot (`!current`)
// is a new leaf, so only then do we advance the pointer; rewriting an
// existing snapshot (heartbeat / status update) never changes leaf-ness, so
// we leave the pointer alone. Snapshots without a `sessionId` are not
// addressable by session, so skip the pointer.
const sessionId = full.sessionId ?? full.state?.sessionId;
if (sessionId && !current) {
await this.writePointer(sessionId, id, options);
}
if (this.maxPersistedChainLength && this.maxPersistedChainLength > 0) {
let cur: SessionSnapshot<S> | undefined = full;
const chain: string[] = [];
while (cur) {
chain.push(cur.snapshotId);
if (cur.parentId) {
cur = await this.getSnapshotById(cur.parentId, options);
} else {
break;
}
}
if (chain.length > this.maxPersistedChainLength) {
for (let i = this.maxPersistedChainLength; i < chain.length; i++) {
const pathToDelete = await this.getFilePath(chain[i], options);
await fsp.unlink(pathToDelete).catch((e: unknown) => {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
});
}
}
}
return id;
}
/**
* Writes `contents` to `filePath` atomically: write to a temp file in the
* same directory, then rename over the target. `rename` is atomic on POSIX
* and Windows, so a concurrent reader in {@link getSnapshot} never observes a
* half-written (torn) file.
*/
private async atomicWrite(filePath: string, contents: string): Promise<void> {
const tmpPath = `${filePath}.${process.pid}.${globalThis.crypto.randomUUID()}.tmp`;
try {
await fsp.writeFile(tmpPath, contents, 'utf-8');
await fsp.rename(tmpPath, filePath);
} catch (e) {
await fsp.unlink(tmpPath).catch(() => {});
throw e;
}
}
/**
* Watches a single snapshot file for changes and invokes `callback` with the
* parsed snapshot whenever it changes.
*
* Unlike {@link InMemorySessionStore}, file-backed snapshots are frequently
* mutated by a *different* process (e.g. the request handler that received an
* abort writes `status: 'aborted'`, while a detached background worker is the
* one watching). Detecting that requires observing the filesystem rather than
* in-process `saveSnapshot` calls.
*
* Reliability comes from two layers:
* - `fs.watch` on the (per-tenant) prefix directory, filtered to the target
* `<snapshotId>.json`. This is low latency but can miss events on some
* filesystems (network mounts, certain container volumes).
* - A polling fallback (`snapshotWatchPollIntervalMs`) that re-reads the file
* on an interval, backstopping any events `fs.watch` drops. Its timer is
* `unref`'d so it never keeps the process alive on its own.
*
* Callbacks are de-duplicated by serialized content, so the noisy/duplicate
* events `fs.watch` emits collapse into one callback per real change.
* Transient read errors (e.g. a partially written file mid-rewrite, or a
* not-yet-created file) are swallowed; the next event/poll re-reads.
*
* @returns An unsubscribe function that stops watching and polling.
*/
onSnapshotStateChange(
snapshotId: string,
callback: (snapshot: SessionSnapshot<S>) => void,
options?: SessionStoreOptions
): void | (() => void) {
const dir = this.prefixDir(options);
fs.mkdirSync(dir, { recursive: true });
const fileName = `${snapshotId}.json`;
const filePath = path.join(dir, fileName);
let closed = false;
let lastSerialized: string | undefined;
// Re-read the file and fire the callback only when the content actually
// changed. `fs.watch` fires multiple events per write, so dedupe by
// serialized content.
const emitIfChanged = async () => {
if (closed) return;
let contents: string;
try {
contents = await fsp.readFile(filePath, 'utf-8');
} catch (e: unknown) {
// Missing file (not yet created) or a transient read error during a
// concurrent rewrite: ignore and wait for the next event/poll.
return;
}
if (closed || contents === lastSerialized) return;
let snapshot: SessionSnapshot<S>;
try {
snapshot = JSON.parse(contents) as SessionSnapshot<S>;
} catch {
// Partially written file mid-rewrite: skip without updating
// lastSerialized so the next event/poll re-reads the complete file.
return;
}
lastSerialized = contents;
callback(snapshot);
};
// Watch the directory (not the file) so this still works before the file
// exists and survives atomic rename-replace writes that swap the inode.
let watcher: fs.FSWatcher | undefined;
try {
watcher = fs.watch(dir, (_event, changed) => {
// `changed` can be null on some platforms; re-check in that case.
if (!changed || changed === fileName) void emitIfChanged();
});
} catch {
// Some environments disallow fs.watch; the polling fallback covers us.
}
// Polling fallback: backstops events fs.watch may drop. `unref` so the
// timer never keeps the process alive on its own.
const pollTimer = setInterval(() => {
void emitIfChanged();
}, this.snapshotWatchPollIntervalMs);
pollTimer.unref?.();
// Surface the current state immediately (if the file already exists).
void emitIfChanged();
return () => {
closed = true;
watcher?.close();
clearInterval(pollTimer);
};
}
}