UNPKG

@genkit-ai/google-cloud

Version:

Genkit AI framework plugin for Google Cloud Platform including Firestore trace/state store and deployment helpers for Cloud Functions for Firebase.

870 lines (817 loc) 33.6 kB
/** * 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 { Firestore, type CollectionReference, type DocumentReference, type DocumentSnapshot, type Transaction, } from '@google-cloud/firestore'; import { GenkitError, applyPatch, diff, type JsonPatch, type SessionSnapshot, type SessionState, type SessionStore, type SessionStoreOptions, type SnapshotMutator, } from 'genkit/beta'; import { logger } from 'genkit/logging'; /** * Default number of turns between full-state checkpoints. * * Chosen to favor the common chat / `useChat` workload, where per-turn state is * small and read cost dominates. Per-save reconstruction reads grow ~linearly * with the interval while checkpoint write/storage cost shrinks with it, so the * op-cost optimum is roughly `sqrt(6 * checkpointShardCount)` (≈small for tiny * state); 25 sits near that optimum for chat while staying conservative for * larger states. Raise it (e.g. 50-100) for large per-turn state retained * long-term; lower it (e.g. 10) for small-state, read-heavy sessions. */ const DEFAULT_CHECKPOINT_INTERVAL = 25; /** * Default maximum size (in bytes) of a single shard / diff document. Kept well * under Firestore's 1 MiB per-document limit so that no individual write can be * rejected for being too large. */ const DEFAULT_SHARD_SIZE = 512 * 1024; /** * Fallback prefix used when no {@link FirestoreSessionStoreOptions.snapshotPathPrefix} * is configured. */ const DEFAULT_PREFIX = 'global'; /** * Options for {@link FirestoreSessionStore}. */ export interface FirestoreSessionStoreOptions { /** * An explicit Firestore instance. Defaults to a new {@link Firestore} * instance (which picks up Application Default Credentials and the * `FIRESTORE_EMULATOR_HOST` environment variable). */ db?: Firestore; /** * The collection where snapshot documents are stored (keyed by * `snapshotId`). Defaults to `"genkit-sessions"`. Two companion collections * are derived from it: `"<collection>-pointers"` holds one pointer document * per session and `"<collection>-shards"` holds the sharded checkpoint * state. */ collection?: string; /** * Number of turns between full-state checkpoints. A larger value stores * fewer (but reconstructs over more) diffs; a smaller value reconstructs * faster at the cost of more frequent full-state writes. Defaults to * {@link DEFAULT_CHECKPOINT_INTERVAL}. * * Cost tuning: per-save reconstruction reads grow ~linearly with this value * (so per-interval read work is ~quadratic in it), while checkpoint write and * storage cost shrink with it. Lower it (e.g. 10) for small-state, read-heavy * sessions; raise it (e.g. 50-100) for large per-turn state retained for a * long time, where checkpoint write/storage amplification dominates. */ checkpointInterval?: number; /** * Maximum size in bytes of a single shard / diff document. Checkpoint state * is split into chunks of this size, and any diff exceeding it is promoted to * a (sharded) checkpoint so that no document approaches Firestore's 1 MiB * limit. Defaults to {@link DEFAULT_SHARD_SIZE}. */ shardSize?: number; /** * Returns a per-tenant prefix derived from the call's * {@link SessionStoreOptions} (e.g. the authenticated user id from * `options.context`). When set, all snapshot, pointer and shard documents are * nested under a tenant-scoped subcollection keyed by this prefix, so reads * and writes are isolated per tenant: one tenant can never see (or even * address) another's snapshots, even if they get hold of a `snapshotId` - * resolving it still requires the matching, auth-derived prefix. Defaults to * `"global"`. * * The prefix is used only to build document paths; the stored ids * (`snapshotId`, `parentId`, `checkpointId`, ...) remain prefix-agnostic. */ snapshotPathPrefix?: (options?: SessionStoreOptions) => string; } /** * The persisted shape of a snapshot document. * * A session's history is stored as a chain of per-turn documents. To keep * reads and document sizes bounded regardless of how long a session grows, * documents come in two `kind`s: * * - `checkpoint` - a full materialization of the session state at that turn. * The state itself is stored *out of band*, sharded across the shards * collection (see {@link ShardDoc}), so a checkpoint never approaches the * 1 MiB document limit. Written for the session root, every * `checkpointInterval` turns, and whenever a single turn's diff would be too * large. * - `diff` - only the {@link JsonPatch} (`statePatch`) that transforms its * parent's state into its own. * * Every document carries the metadata needed to reconstruct it with a single * batched, strongly-consistent `getAll` (no queries / secondary indexes): * `checkpointId` (the nearest checkpoint ancestor), `checkpointShardCount`, and * `segmentPath` (the ordered diff IDs from that checkpoint down to this * document). Because `segmentPath` resets at every checkpoint, the *number of * diff documents* read per reconstruction is bounded by `checkpointInterval`, * not by total session length. (The number of shard documents still scales * with the state's size - i.e. with session length - since each checkpoint * stores the full accumulated state.) */ interface SnapshotDoc { snapshotId: string; sessionId: string; parentId?: string; createdAt: string; updatedAt?: string; status?: SessionSnapshot['status']; /** Heartbeat timestamp (RFC 3339) for an in-flight detached turn. */ heartbeatAt?: SessionSnapshot['heartbeatAt']; finishReason?: SessionSnapshot['finishReason']; error?: SessionSnapshot['error']; /** `checkpoint` stores full state in shards; `diff` stores `statePatch`. */ kind: 'diff' | 'checkpoint'; /** Nearest checkpoint ancestor (equals `snapshotId` when a checkpoint). */ checkpointId: string; /** Shard count of the checkpoint identified by `checkpointId`. */ checkpointShardCount: number; /** * Ordered diff IDs from the checkpoint (exclusive) to this document * (inclusive). Empty for a checkpoint. Applying these patches in order onto * the checkpoint's state materializes this document's state. */ segmentPath: string[]; /** RFC 6902 patch from the parent's state. Only set for `kind: 'diff'`. */ statePatch?: JsonPatch; } /** * One shard of a checkpoint's materialized state. The full state is * JSON-serialized to UTF-8 and split into byte-bounded chunks stored at * `<checkpointId>_<index>`; concatenating the chunks and parsing the result * yields the original state. */ interface ShardDoc { chunk: Buffer; } /** * The per-session pointer document. Tracks the current leaf snapshot and the * metadata needed to reconstruct it (its checkpoint, shard count and segment * path) so the common `sessionId` lookup is a single pointer read followed by * one batched `getAll`. It deliberately does *not* cache the full state, so it * can never approach the 1 MiB document limit no matter how long the session * grows. */ interface PointerDoc { currentSnapshotId: string; checkpointId: string; checkpointShardCount: number; segmentPath: string[]; updatedAt: string; } /** * Chain metadata about a parent snapshot needed to extend the chain - the * nearest checkpoint, its shard count and the diff segment leading to the * parent. Deliberately excludes the parent's (potentially large) state so it * can be resolved without a full reconstruction. */ interface ParentChainMeta { checkpointId: string; checkpointShardCount: number; segmentPath: string[]; } /** * A minimal batched read interface so reconstruction can run identically * against a transaction or the bare Firestore instance. Both `get` and * `getAll` are document-ID lookups, which Firestore serves with strong * consistency (unlike queries), keeping reconstruction deterministic. */ interface Reader { get(ref: DocumentReference): Promise<DocumentSnapshot>; getAll(refs: DocumentReference[]): Promise<DocumentSnapshot[]>; } /** * Strips `undefined` members (Firestore rejects them) while preserving JSON * semantics - matching how snapshot state is diffed and reconstructed. */ function sanitize<T>(value: T): T { return JSON.parse(JSON.stringify(value ?? null)); } /** * A Firestore-backed {@link SessionStore} that persists session snapshots as * incremental JSON Patch diffs anchored to periodic, sharded full-state * checkpoints. * * Storage layout (the `<prefix>` segment is the per-tenant prefix returned by * {@link FirestoreSessionStoreOptions.snapshotPathPrefix}, or `"global"` when * none is configured): * * - `<collection>/<prefix>/snapshots/<snapshotId>` - one document per snapshot. * A `diff` document holds the patch from its parent (`statePatch`); a * `checkpoint` document holds a full-state materialization (sharded out of * band). * - `<collection>-shards/<prefix>/shards/<checkpointId>_<index>` - the sharded * full state for a checkpoint. * - `<collection>-pointers/<prefix>/pointers/<sessionId>` - one document per * session pointing at the latest leaf snapshot and the metadata needed to * reconstruct it. * * Reconstruction uses only document-ID lookups (`getAll`), so it needs no * secondary indexes and is strongly consistent. No single document approaches * the 1 MiB limit (state is sharded by `shardSize`), and the number of *diff* * documents touched per read/write is bounded by `checkpointInterval` rather * than total session length - so the store scales to arbitrarily long sessions * (e.g. coding agents, long-lived chatbots). Note that checkpoints still store * the full accumulated state, so checkpoint shard count (and the bytes written * per checkpoint) grow with the state's size; tune `checkpointInterval` to * trade per-save diff reads against checkpoint write amplification. */ export class FirestoreSessionStore<S = unknown> implements SessionStore<S> { protected db: Firestore; protected collection: string; protected checkpointInterval: number; protected shardSize: number; protected snapshotPathPrefix?: (options?: SessionStoreOptions) => string; constructor(opts?: FirestoreSessionStoreOptions) { this.db = opts?.db ?? new Firestore(); this.collection = opts?.collection ?? 'genkit-sessions'; this.checkpointInterval = opts?.checkpointInterval ?? DEFAULT_CHECKPOINT_INTERVAL; this.shardSize = opts?.shardSize ?? DEFAULT_SHARD_SIZE; this.snapshotPathPrefix = opts?.snapshotPathPrefix; } /** Resolves the (per-tenant) prefix for the given call options. */ private prefixFor(options?: SessionStoreOptions): string { return this.snapshotPathPrefix ? this.snapshotPathPrefix(options) : DEFAULT_PREFIX; } /** The (per-tenant) snapshots subcollection. */ private snapshotsCol(options?: SessionStoreOptions): CollectionReference { return this.db .collection(this.collection) .doc(this.prefixFor(options)) .collection('snapshots'); } /** The (per-tenant) pointers subcollection. */ private pointersCol(options?: SessionStoreOptions): CollectionReference { return this.db .collection(`${this.collection}-pointers`) .doc(this.prefixFor(options)) .collection('pointers'); } /** The (per-tenant) shards subcollection. */ private shardsCol(options?: SessionStoreOptions): CollectionReference { return this.db .collection(`${this.collection}-shards`) .doc(this.prefixFor(options)) .collection('shards'); } async getSnapshot(opts: { snapshotId?: string; sessionId?: string; context?: SessionStoreOptions['context']; }): Promise<SessionSnapshot<S> | undefined> { const { snapshotId, sessionId } = this.normalize(opts); const options: SessionStoreOptions = { context: opts.context }; // Reconstruct inside a read-only transaction so the pointer read and the // batched shard/diff reads all observe a single, consistent point in time. // Without this, a concurrent checkpoint write - which overwrites a // checkpoint's shards in place and may delete now-stale trailing shards // (see `writeShards`) - could let a reader stitch together a mix of old and // new chunks, yielding a `DATA_LOSS` (missing shard) error or a corrupt // `JSON.parse`. A read-only transaction also avoids the contention/retries // of a read-write one. return this.db.runTransaction( async (tx) => { const reader = this.reader(tx); if (sessionId) { const pointerSnap = await tx.get( this.pointersCol(options).doc(sessionId) ); if (!pointerSnap.exists) return undefined; const pointer = pointerSnap.data() as PointerDoc; // Reconstruct straight from the pointer's checkpoint metadata - one // batched round-trip, no extra read of the leaf document. const reconstructed = await this.reconstructFrom( reader, pointer.checkpointId, pointer.checkpointShardCount, pointer.segmentPath, pointer.currentSnapshotId, options ); if (!reconstructed) return undefined; return this.toSnapshot(reconstructed.doc, reconstructed.state); } const reconstructed = await this.reconstruct( reader, snapshotId!, options ); if (!reconstructed) return undefined; return this.toSnapshot(reconstructed.doc, reconstructed.state); }, { readOnly: true } ); } async saveSnapshot( snapshotId: string | undefined, mutator: SnapshotMutator<S>, options?: SessionStoreOptions ): Promise<string | null> { return this.db.runTransaction(async (tx) => { const reader = this.reader(tx); // Reads phase 1: load the existing snapshot (if any) so the mutator can // inspect the current full state. let existing: { doc: SnapshotDoc; state: SessionState<S> } | undefined; if (snapshotId) { existing = await this.reconstruct(reader, snapshotId, options); } const current = existing ? this.toSnapshot(existing.doc, existing.state) : undefined; const result = mutator(current); if (result === null) return null; const id = snapshotId || result.snapshotId || globalThis.crypto.randomUUID(); // Prefer the snapshot's top-level `sessionId`; fall back to the id carried // in its state for rows written before snapshot-level ids existed. const sessionId = result.sessionId ?? result.state?.sessionId; if (!sessionId) { throw new GenkitError({ status: 'INVALID_ARGUMENT', message: `FirestoreSessionStore requires 'sessionId' to be set on the snapshot.`, }); } const newState = (result.state ?? {}) as SessionState<S>; // Reads phase 2: the per-session pointer (current leaf metadata). const pointerRef = this.pointersCol(options).doc(sessionId); const pointerSnap = await tx.get(pointerRef); const pointer = pointerSnap.exists ? (pointerSnap.data() as PointerDoc) : undefined; let kind: 'diff' | 'checkpoint'; let checkpointId: string; let checkpointShardCount: number; let segmentPath: string[]; let statePatch: JsonPatch | undefined; if (existing) { // Upsert: preserve the document's role and chain position; only the // state/metadata change. Callers must only upsert the *leaf* - // rewriting a non-leaf snapshot's state would invalidate its // descendants' diffs. if (existing.doc.kind === 'checkpoint') { ({ kind, checkpointId, checkpointShardCount, segmentPath, statePatch, } = this.writeCheckpoint( tx, id, newState, options, existing.doc.checkpointShardCount )); } else { // Reads phase 3 (diff upsert): resolve parent state for the patch. const parentState = existing.doc.parentId ? (await this.reconstruct(reader, existing.doc.parentId, options)) ?.state : undefined; const candidatePatch = diff(parentState, newState); // Promote an oversized diff to a (sharded) checkpoint so even an // in-place leaf rewrite can never push the document past the 1 MiB // limit. Safe because callers only upsert the leaf, which has no // descendants depending on its chain position. if (this.byteLength(candidatePatch) > this.shardSize) { ({ kind, checkpointId, checkpointShardCount, segmentPath, statePatch, } = this.writeCheckpoint(tx, id, newState, options)); } else { kind = 'diff'; checkpointId = existing.doc.checkpointId; checkpointShardCount = existing.doc.checkpointShardCount; segmentPath = existing.doc.segmentPath; statePatch = candidatePatch; } } } else { // New snapshot: resolve the parent's *chain metadata* (no state) to // decide diff vs checkpoint. Materializing the parent's full state is // deferred until we know we actually need a diff - so the expensive // reconstruction is skipped on every checkpoint-boundary turn (which // would rewrite the whole state regardless). let parentMeta: ParentChainMeta | undefined; if (result.parentId) { parentMeta = await this.loadParentChainMeta( reader, result.parentId, pointer, options ); } if ( !result.parentId || !parentMeta || parentMeta.segmentPath.length + 1 >= this.checkpointInterval ) { // Write a full checkpoint without ever reconstructing the parent's // state, for any of: a session root, an orphaned parent, or reaching // the checkpoint interval (whose final segment is exactly the longest, // costliest one we'd otherwise pay to reconstruct here). ({ kind, checkpointId, checkpointShardCount, segmentPath, statePatch, } = this.writeCheckpoint(tx, id, newState, options)); } else { // Diff candidate: now we must materialize the parent's state to // compute the patch. const parentState = ( await this.reconstructFrom( reader, parentMeta.checkpointId, parentMeta.checkpointShardCount, parentMeta.segmentPath, result.parentId, options ) )?.state; const candidatePatch = diff(parentState, newState); // Promote oversized diffs to checkpoints so a single large turn is // sharded rather than rejected by the 1 MiB limit. if (this.byteLength(candidatePatch) > this.shardSize) { ({ kind, checkpointId, checkpointShardCount, segmentPath, statePatch, } = this.writeCheckpoint(tx, id, newState, options)); } else { kind = 'diff'; checkpointId = parentMeta.checkpointId; checkpointShardCount = parentMeta.checkpointShardCount; segmentPath = [...parentMeta.segmentPath, id]; statePatch = candidatePatch; } } } // Writes phase. const doc: SnapshotDoc = { snapshotId: id, sessionId, parentId: result.parentId, createdAt: result.createdAt, updatedAt: result.updatedAt ?? result.createdAt, status: result.status, heartbeatAt: result.heartbeatAt, finishReason: result.finishReason, error: result.error, kind, checkpointId, checkpointShardCount, segmentPath, statePatch, }; tx.set(this.snapshotsCol(options).doc(id), sanitize(doc)); // Advance the pointer when this is a new leaf, or refresh it when we just // rewrote the current leaf. Upserts of older, non-leaf snapshots leave // the pointer untouched. const isNew = !existing; if (isNew || !pointer || pointer.currentSnapshotId === id) { tx.set( pointerRef, sanitize<PointerDoc>({ currentSnapshotId: isNew || !pointer ? id : pointer.currentSnapshotId, checkpointId, checkpointShardCount, segmentPath, updatedAt: new Date().toISOString(), }) ); } return id; }); } onSnapshotStateChange( snapshotId: string, callback: (snapshot: SessionSnapshot<S>) => void, options?: SessionStoreOptions ): void | (() => void) { const ref = this.snapshotsCol(options).doc(snapshotId); return ref.onSnapshot(async (docSnap) => { if (!docSnap.exists) return; try { const snapshot = await this.getSnapshot({ snapshotId, context: options?.context, }); if (snapshot) callback(snapshot); } catch (err) { // Swallow errors so a transient read failure (network / permission) // doesn't surface as an unhandled promise rejection and crash the // process. The next snapshot event will retry. logger.error( `FirestoreSessionStore.watch failed to load snapshot ${snapshotId}`, err ); } }); } /** * Validates that exactly one of `snapshotId` / `sessionId` is provided, and * that the provided one is a non-blank string. Blank / whitespace-only ids * are rejected up front (rather than silently treated as "absent") so callers * get a clear `INVALID_ARGUMENT` instead of an unusable document key. */ private normalize(opts: { snapshotId?: string; sessionId?: string }): { snapshotId?: string; sessionId?: string; } { const snapshotId = opts.snapshotId?.trim() ? opts.snapshotId : undefined; const sessionId = opts.sessionId?.trim() ? opts.sessionId : undefined; if (!!snapshotId === !!sessionId) { throw new GenkitError({ status: 'INVALID_ARGUMENT', message: `getSnapshot requires exactly one non-empty 'snapshotId' or ` + `'sessionId' (got ${snapshotId ? 'snapshotId' : opts.snapshotId !== undefined ? 'blank snapshotId' : 'neither'}` + `${sessionId ? ' and sessionId' : opts.sessionId !== undefined ? ' and blank sessionId' : ''}).`, }); } return { snapshotId, sessionId }; } /** Builds a {@link Reader} bound to a transaction or the bare instance. */ private reader(tx?: Transaction): Reader { if (tx) { return { get: (ref) => tx.get(ref), getAll: (refs) => refs.length ? tx.getAll(...refs) : Promise.resolve([]), }; } return { get: (ref) => ref.get(), getAll: (refs) => refs.length ? this.db.getAll(...refs) : Promise.resolve([]), }; } /** * Resolves a parent's chain metadata (nearest checkpoint, shard count and * segment path) *without* materializing its - potentially large - state. * * In the common linear case the parent is the session's current leaf, so the * metadata is read straight off the pointer and this performs *zero* * document reads. Otherwise it reads the single parent document. Crucially, * resolving only the metadata lets `saveSnapshot` decide diff-vs-checkpoint * (which only needs `segmentPath.length`) before paying for a full state * reconstruction - so checkpoint-boundary turns, which would rewrite the * whole state anyway, skip reconstruction entirely. */ private async loadParentChainMeta( reader: Reader, parentId: string, pointer: PointerDoc | undefined, options?: SessionStoreOptions ): Promise<ParentChainMeta | undefined> { if (pointer && pointer.currentSnapshotId === parentId) { return { checkpointId: pointer.checkpointId, checkpointShardCount: pointer.checkpointShardCount, segmentPath: pointer.segmentPath, }; } const snap = await reader.get(this.snapshotsCol(options).doc(parentId)); if (!snap.exists) return undefined; const d = snap.data() as SnapshotDoc; return { checkpointId: d.checkpointId, checkpointShardCount: d.checkpointShardCount, segmentPath: d.segmentPath, }; } /** * Reconstructs the state of `id` by reading its document to learn its * checkpoint and segment path, then materializing from that checkpoint. * Returns `undefined` when the snapshot does not exist. */ private async reconstruct( reader: Reader, id: string, options?: SessionStoreOptions ): Promise<{ doc: SnapshotDoc; state: SessionState<S> } | undefined> { const snap = await reader.get(this.snapshotsCol(options).doc(id)); if (!snap.exists) return undefined; const d = snap.data() as SnapshotDoc; return this.reconstructFrom( reader, d.checkpointId, d.checkpointShardCount, d.segmentPath, id, options ); } /** * Materializes the state of `targetId` from a known checkpoint using a single * batched, strongly-consistent `getAll`: the checkpoint's shards, the * (bounded) segment of diff documents along `segmentPath`, and - only when * the target *is* the checkpoint - the checkpoint document itself. The diffs * are then applied in order onto the checkpoint's state. Cost is bounded by * `checkpointInterval` + shard count, independent of total session length. * * Note: when `segmentPath` is non-empty the state comes entirely from the * shards and the target's metadata from the last segment document, so the * checkpoint *document* is not read - saving one read on the common path. */ private async reconstructFrom( reader: Reader, checkpointId: string, shardCount: number, segmentPath: string[], targetId: string, options?: SessionStoreOptions ): Promise<{ doc: SnapshotDoc; state: SessionState<S> } | undefined> { const targetIsCheckpoint = segmentPath.length === 0; const snapshotsCol = this.snapshotsCol(options); const shardsCol = this.shardsCol(options); const checkpointRef = snapshotsCol.doc(checkpointId); const shardRefs = Array.from({ length: shardCount }, (_, i) => shardsCol.doc(`${checkpointId}_${i}`) ); const segRefs = segmentPath.map((sid) => snapshotsCol.doc(sid)); const snaps = await reader.getAll([ // The checkpoint document is only needed when it is itself the target; // otherwise the last segment document carries the target metadata. ...(targetIsCheckpoint ? [checkpointRef] : []), ...shardRefs, ...segRefs, ]); // `getAll` does not guarantee result order matches request order, so index // the snapshots by their (fully-qualified) path and look each up explicitly. const byPath = new Map<string, DocumentSnapshot>(); for (const s of snaps) byPath.set(s.ref.path, s); const shardSnaps = shardRefs.map((ref) => byPath.get(ref.path)!); let state = this.stitch(shardSnaps) as SessionState<S> | undefined; if (targetIsCheckpoint) { const checkpointSnap = byPath.get(checkpointRef.path); if (!checkpointSnap?.exists) return undefined; const checkpointDoc = checkpointSnap.data() as SnapshotDoc; if (checkpointDoc.snapshotId !== targetId) return undefined; return { doc: checkpointDoc, state: (state ?? {}) as SessionState<S> }; } let targetDoc: SnapshotDoc | undefined; for (const ref of segRefs) { const segSnap = byPath.get(ref.path); if (!segSnap?.exists) return undefined; // Missing diff: corrupt chain. const segDoc = segSnap.data() as SnapshotDoc; state = applyPatch(state, segDoc.statePatch ?? []); targetDoc = segDoc; } if (!targetDoc || targetDoc.snapshotId !== targetId) return undefined; return { doc: targetDoc, state: (state ?? {}) as SessionState<S> }; } /** * Serializes `state` to UTF-8, splits it into `shardSize`-byte chunks, and * writes them at `<checkpointId>_<index>`. When `oldShardCount` exceeds the * new count (a shrinking re-checkpoint), the now-stale trailing shards are * deleted. Returns the number of shards written. */ private writeShards( tx: Transaction, checkpointId: string, state: SessionState<S>, options?: SessionStoreOptions, oldShardCount = 0 ): number { const shardsCol = this.shardsCol(options); // `JSON.stringify` already drops `undefined` members, so it produces the // same bytes as `sanitize(state)` without the extra parse+stringify round // trip - a meaningful saving when checkpointing large states. const buf = Buffer.from(JSON.stringify(state ?? null), 'utf8'); const count = Math.max(1, Math.ceil(buf.length / this.shardSize)); for (let i = 0; i < count; i++) { // Copy the slice into its own buffer. `subarray` returns a view sharing // the parent's underlying ArrayBuffer, which the Firestore serializer can // persist in full rather than just the sliced range. const chunk = Buffer.from( buf.subarray(i * this.shardSize, (i + 1) * this.shardSize) ); tx.set(shardsCol.doc(`${checkpointId}_${i}`), { chunk, } satisfies ShardDoc); } for (let i = count; i < oldShardCount; i++) { tx.delete(shardsCol.doc(`${checkpointId}_${i}`)); } return count; } /** * Writes a full-state checkpoint at `id` (sharding the state via * {@link writeShards}) and returns the snapshot metadata describing it: a * checkpoint anchors itself (`checkpointId === id`), has an empty * `segmentPath`, and carries no `statePatch`. * * This is the shared promotion path used whenever a snapshot must be a * checkpoint rather than a diff - the session root, an orphaned parent, a * checkpoint-interval boundary, an in-place checkpoint rewrite, and the * promotion of an oversized diff (whether new turn or leaf upsert) so that no * single document approaches Firestore's 1 MiB limit. Pass `oldShardCount` * when re-checkpointing an existing checkpoint so stale trailing shards are * pruned. */ private writeCheckpoint( tx: Transaction, id: string, state: SessionState<S>, options?: SessionStoreOptions, oldShardCount = 0 ): { kind: 'checkpoint'; checkpointId: string; checkpointShardCount: number; segmentPath: string[]; statePatch: undefined; } { return { kind: 'checkpoint', checkpointId: id, checkpointShardCount: this.writeShards( tx, id, state, options, oldShardCount ), segmentPath: [], statePatch: undefined, }; } /** Concatenates ordered shard documents and parses the materialized state. */ private stitch(shardSnaps: DocumentSnapshot[]): unknown { if (shardSnaps.length === 0) return undefined; const buffers: Buffer[] = []; for (const s of shardSnaps) { if (!s.exists) { throw new GenkitError({ status: 'DATA_LOSS', message: `FirestoreSessionStore: missing checkpoint shard '${s.id}'.`, }); } buffers.push((s.data() as ShardDoc).chunk); } return JSON.parse(Buffer.concat(buffers).toString('utf8')); } /** UTF-8 byte length of a JSON-serializable value. */ private byteLength(value: unknown): number { return Buffer.byteLength(JSON.stringify(value ?? null), 'utf8'); } /** Assembles a {@link SessionSnapshot} from a document and its state. */ private toSnapshot( doc: SnapshotDoc, state: SessionState<S> ): SessionSnapshot<S> { const snapshot: SessionSnapshot<S> = { snapshotId: doc.snapshotId, sessionId: doc.sessionId, createdAt: doc.createdAt, // Normalize to plain objects: values reconstructed from Firestore // documents (e.g. patch operands) can carry non-plain prototypes. state: sanitize(state), }; if (doc.parentId !== undefined) snapshot.parentId = doc.parentId; if (doc.updatedAt !== undefined) snapshot.updatedAt = doc.updatedAt; if (doc.status !== undefined) snapshot.status = doc.status; if (doc.heartbeatAt !== undefined) snapshot.heartbeatAt = doc.heartbeatAt; if (doc.finishReason !== undefined) snapshot.finishReason = doc.finishReason; if (doc.error !== undefined) snapshot.error = doc.error; return snapshot; } }