UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

1,012 lines • 58.2 kB
"use strict";
/**
 * firestoreSessions — conversations in Firestore, so a fleet shares them and
 * nobody runs a database.
 *
 * The ladder this rung sits on is already in the package. `memorySessions()`
 * loses everything on restart and says so. `sqliteSessions()` survives a restart
 * on ONE machine and says so. `agentEngineSessions()` is a fleet store, but only
 * for people who already own a Vertex reasoning engine. Firestore is the row for
 * everyone else on this column: a serverless document database with no instance
 * to size, no connection pool to tune, and a free tier — the plainest "many
 * containers, one conversation" answer Google has.
 *
 * ── The shape, said plainly ─────────────────────────────────────────────────
 * One collection. One document per session. Seven fields:
 *
 *   sessionId · format · savedAt · envelope · owner · messageCount · expiresAt
 *
 * That is the SQLite table, moved. It is deliberately the same shape, because
 * the two stores implement the same port under the same laws, and a reader who
 * has understood one should not have to learn a second model to audit the other.
 *
 * The envelope rides as a JSON **string**, not as a nested map, and that is a
 * decision rather than laziness. A `CheckpointEnvelope` carries arbitrary
 * conversation JSON: keys chosen by a model's tool call, values that may be
 * `undefined`, arrays inside arrays. Firestore refuses all three — a field name
 * may not contain a dot, a tilde, a star, a slash, a bracket or a backtick;
 * `undefined` throws unless the client was
 * built with `ignoreUndefinedProperties`, and a directly nested array is not a
 * representable value. Serialising once at the edge makes every one of those a
 * non-event, at the cost of not being able to query INSIDE a conversation —
 * which no caller of this port has ever asked to do.
 *
 * ── Why the document id is a hash ───────────────────────────────────────────
 * A `sessionId` in this library is OPAQUE. It may be a UUID, an upstream
 * gateway's correlation id, a path-shaped tenant key, or a unicode string a
 * person typed. Firestore document names have rules: no `/`, not `.` or `..`,
 * not matching `__.*__`, and at most 1500 bytes. A session id that broke any of
 * them would fail at the wire on the one turn it mattered — or worse, two ids
 * that differ only past a truncation point would silently become ONE
 * conversation.
 *
 * So the document name is `sha256(domain + NUL + sessionId)` in hex — 64
 * characters, always legal, injective for every input anybody will ever have.
 * The raw id is stored in the `sessionId` FIELD, so a listing can hand it back
 * and a console reader can still see whose document they are looking at.
 *
 * **This is not encryption, and it is important not to read it as any.** The
 * conversation itself is stored in the clear; the hash is an addressing scheme,
 * not a confidentiality control. Anyone who can read the collection can read
 * every conversation in it, and the `sessionId` field beside the hash spells out
 * the id the hash was made from. Two things it DOES cost an operator, stated
 * because they are discovered at the worst moment otherwise:
 *
 *   • you cannot look a session up in the Firestore console by typing its raw
 *     id — you have to query `sessionId == '…'`, or hash it yourself;
 *   • a document name carries no information a human can sort or scan by.
 *
 * Encryption at rest is Google's (always on, and configurable with CMEK).
 * Access control is IAM's. Neither is this adapter's, and neither is implied by
 * the hash.
 *
 * ── The composite index, and the error you get without it ───────────────────
 * `listByUser` runs one server-side query — an equality on `owner`, ordered by
 * `savedAt` descending, paged with a real Firestore cursor. Firestore's
 * automatic single-field indexes do NOT serve that shape: an equality filter on
 * one field ordered by another needs a COMPOSITE index, and until it exists the
 * query fails with gRPC status 9, `FAILED_PRECONDITION`.
 *
 * The index, exactly:
 *
 *   collection group : <your collection>   (default: agentfootprint_sessions)
 *   fields           : owner    Ascending
 *                      savedAt  Descending
 *                      __name__ Descending
 *
 * `__name__` is Firestore's document-name field. It is the tiebreaker this
 * adapter orders by explicitly (see {@link FirestoreSessions.listByUser}), and
 * an index's trailing `__name__` takes the direction of the last ordered field —
 * so a console-generated index for `owner ASC, savedAt DESC` is the right one.
 *
 *   gcloud firestore indexes composite create \
 *     --collection-group=agentfootprint_sessions \
 *     --field-config=field-path=owner,order=ascending \
 *     --field-config=field-path=savedAt,order=descending \
 *     --database='(default)'
 *
 * `--database` is spelled out rather than left to gcloud's default, and it must
 * match the `database` this store was built with. gcloud assumes `(default)` when
 * the flag is absent, so an operator on a NAMED database who follows a command
 * without it creates the index somewhere else and gets the identical failure
 * back, with nothing to suggest why.
 *
 * When the index is missing this adapter raises {@link FirestoreIndexMissingError},
 * which prints that same line with your collection and database already filled
 * in, and names those fields rather than restating the service's message — see
 * {@link firestoreFailure} for why no Google text is ever echoed here.
 *
 * ── Retention: a SECOND timestamp, not a converted one (9.42.0) ─────────────
 * A native Firestore TTL policy deletes a document when a field of type
 * **Timestamp** is in the past. `savedAt` is a NUMBER — epoch milliseconds —
 * so until this release an operator could not point a policy at this
 * collection at all without a library change. The fix is a second field,
 * `expiresAt`, written as a `Date` (the client stores one as a Timestamp) on
 * every persist, holding `savedAt + expireAfterMs`. Give the store no
 * `expireAfterMs` and the field is written as `null`, which a TTL policy
 * ignores, and nothing about this release changes.
 *
 * **`savedAt` was NOT converted, and that is the load-bearing decision.** It is
 * the ordering key of the composite index, the field the listing sorts by, and
 * the first half of every cursor this store has ever minted (`<savedAt>:<docId>`,
 * parsed with `parseFloat` and handed back to `startAfter` as a number).
 * Converting it would have meant a new index, a new cursor grammar, and every
 * pagination token issued by an earlier release becoming unreadable — but the
 * decisive one is quieter: **Firestore's value ordering sorts by TYPE first.**
 * Every number sorts before every timestamp. A collection holding old
 * number-`savedAt` documents beside new timestamp-`savedAt` ones would list
 * them in two blocks, all of one type then all of the other, with a person's
 * conversations silently out of order by the type of the field rather than by
 * when they were written. A second field costs one small write per turn and
 * has no such shadow.
 *
 * So the migration consequence, plainly:
 *
 *   • **Every document already stored stays readable and listable.** Nothing
 *     about `savedAt`, the index, the cursor or the query changed, and a
 *     document written by 9.33–9.41 is read by this release unchanged.
 *   • **Old documents do not start expiring.** They carry no `expiresAt`, and
 *     a TTL policy ignores a document whose field is missing. A conversation
 *     that is still being used gains one on its next turn; a conversation
 *     nobody ever writes to again keeps living. If those matter, delete them
 *     by hand once (a query on `savedAt` plus `forget`, or a `gcloud`
 *     bulk-delete) — this adapter will not walk your collection behind your
 *     back.
 *   • **The policy is still yours to create.** `retention().enableWith` prints
 *     the exact command with your collection and database in it.
 *
 * ── The ceiling, since a store should name its own ──────────────────────────
 * A Firestore document is capped at 1 MiB. A conversation whose stored envelope
 * approaches that is refused BY NAME before the write
 * ({@link EnvelopeTooLargeError}) rather than being sent and rejected as an
 * opaque `INVALID_ARGUMENT`. Nothing is ever truncated: half a conversation
 * stored as if it were whole is the failure this whole file exists to avoid.
 * Compaction (the builder's `.compaction({ … })`, or `.window()`) is the
 * answer, and the refusal says so. Named from the builder deliberately: there
 * is no `agent.compact()` to call, and a doc comment ships into the emitted
 * `.d.ts`, so a method named here that does not exist is a method somebody
 * types on hover.
 *
 * ── The laws it inherits rather than re-implements ──────────────────────────
 * `checkEnvelope` runs on the way OUT and on the way IN, so an envelope whose
 * `format` this runtime does not know is refused by name, and a session that is
 * PRESENT but unreadable is refused by name too. Only a session that was never
 * written hydrates as `undefined`. A conversation that exists and cannot be read
 * must never be answered with a fresh start — from the outside that is
 * indistinguishable from a brand-new user.
 *
 * Ownership is DERIVED from the stored envelope and established ONCE. See
 * `persist` below: SQLite gets that from `COALESCE(sessions.owner,
 * excluded.owner)`, Firestore has no such thing, and a `set({ merge: true })`
 * would let the last writer win — which is exactly the bug, not a workaround
 * for it. So the write is a transaction.
 *
 * ── How much of this is verified ────────────────────────────────────────────
 * **Field-validated except the ownership refusal (2026-08).** An independent
 * field trial ran THIS adapter against a real Firestore and exercised seven of
 * its eight areas live: the round trip, the server-side indexed listing with
 * its document-name tiebreak and real cursor, the missing-index refusal, the
 * hashed document name, the size ceiling, ownership derived from the envelope,
 * and `forget`.
 *
 * The eighth is named because under-claiming and over-claiming are the same
 * defect: **the refusal of a turn signed by somebody else** — the
 * `resolveSessionOwner` conflict this file's `persist` raises out of the
 * transaction — was NOT exercised, because it did not exist when the trial
 * ran. It is held by tests here, including the contested-write case of the
 * shared conformance battery, and by nothing in the field. Two writers racing
 * for one fresh session against a real Firestore, with the real client's
 * transaction retry underneath, is exactly the shape a double models by
 * assumption; the sibling Vertex adapter's equivalent was found in the field
 * and not in a test suite.
 *
 * This section said "NOT field-validated. Nothing here has been run against a
 * live Firestore" for two releases after that stopped being true. Under-claiming
 * is the safe direction and it is still the same defect class the trials keep
 * reporting: a status that does not track the evidence is a status nobody can
 * use, whichever way it is wrong.
 *
 * The pin is the `firestoreSessions` row of `GOOGLE_SURFACE_PINS` in
 * `test/adapters/google/googlePin.ts`, asserted by
 * `test/adapters/google/google-surface-pin.test.ts`. Its 18 members were
 * hand-verified against a real `@google-cloud/firestore` 9.0.0 install in a
 * scratch project OUTSIDE this repository: seventeen read off
 * `types/firestore.d.ts` before a line of this file was written, and
 * `DocumentSnapshot.id` verified afterwards, when a review found the row had
 * pinned `DocumentReference.id` — real, but a member this adapter never reads —
 * in place of the one the cursor actually reads.
 *
 * That package is deliberately NOT installed here. It depends on
 * `@opentelemetry/api`, so installing it hoists that package to the repository
 * root and disarms `test/observability-providers/otel.test.ts`, which proves
 * `otelObservability()` refuses BY NAME when `@opentelemetry/api` is absent.
 * The consequence has to be said plainly: **the reality assertion — "every
 * pinned member really exists on the real package" — SKIPS in this repository.**
 * It runs in full for anyone who installs `@google-cloud/firestore` locally.
 *
 * So what is machine-checked in CI is the SHAPE pin, not the reality pin: this
 * adapter dispatches exactly the members the row names and no others, every run,
 * everywhere. That the row spells those members the way Google does is held by a
 * hand check against a real install, not by a test that runs here.
 *
 * The DESIGN was informed, before any of that, by an EARLIER and separate trial
 * of a DIFFERENT Firestore
 * session adapter, which ran against a real Firestore and passed eight ownership
 * and history checks — and whose own report named the defect this adapter does
 * not reproduce: that adapter read every document for one owner, sorted them in
 * the client, and applied an offset cursor. That works until one person has a
 * lot of conversations, and then it costs a full read of all of them per page.
 * What the trial proves is that the ownership and history SEMANTICS survive a
 * real service; it proves nothing about this file's query, cursor, transaction
 * or index, because that adapter had none of them.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.firestoreFailure = exports.isFailedPrecondition = exports.grpcStatusOf = exports.documentIdFor = exports.firestoreSessions = exports.EnvelopeTooLargeError = exports.FirestoreIndexMissingError = exports.FIRESTORE_MAX_ENVELOPE_BYTES = exports.FIRESTORE_MAX_DOCUMENT_BYTES = exports.ttlPolicyCommand = exports.EXPIRES_AT_FIELD = exports.DEFAULT_SESSION_COLLECTION = void 0;
const node_crypto_1 = require("node:crypto");
const answeredPause_js_1 = require("../../hosting/answeredPause.js");
const envelope_js_1 = require("../../hosting/envelope.js");
const errors_js_1 = require("../../hosting/errors.js");
const sessionOwnership_js_1 = require("../../hosting/sessionOwnership.js");
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const ADAPTER = 'firestoreSessions';
/** Where sessions live when the caller names no collection. */
exports.DEFAULT_SESSION_COLLECTION = 'agentfootprint_sessions';
/**
 * The document field a native TTL policy is configured against (9.42.0).
 *
 * A SECOND field beside `savedAt`, never a change to it — see the module
 * header for why converting the ordering key would have re-sorted every
 * conversation already in the collection.
 *
 * Exported because an operator has to type this name into the console or the
 * `gcloud` line, and a field name that lives only inside a string literal is a
 * field name somebody will mistype at 2am.
 */
exports.EXPIRES_AT_FIELD = 'expiresAt';
/**
 * The one step that turns the TTL policy on, with this store's own collection
 * and database already in it.
 *
 * One spelling, shared by the retention answer and the documentation that
 * quotes it, for the same reason the missing-index refusal prints the whole
 * `gcloud` line: the person reading it can fix this in sixty seconds, and only
 * if nobody makes them go and look up the flags first.
 *
 * `--database` is always printed, including for `(default)`, where gcloud
 * would have assumed it — a project may hold several databases, and a policy
 * created on the wrong one leaves conversations living forever with nothing to
 * suggest why. Single-quoted, because `(default)` is bare parentheses and a
 * syntax error in every shell this will be pasted into.
 */
function ttlPolicyCommand(collection, database) {
    const flagValue = `'${database ?? '<the database your Firestore client was built for>'}'`;
    return (`gcloud firestore fields ttls update ${exports.EXPIRES_AT_FIELD} ` +
        `--collection-group=${collection} --enable-ttl --database=${flagValue}` +
        (database === undefined
            ? ` (this store did not build the Firestore client, so it cannot name the database — ` +
                `fill that flag in from wherever the client was constructed)`
            : ``));
}
exports.ttlPolicyCommand = ttlPolicyCommand;
/** How many rows one `listByUser` page carries when the caller names no limit. */
const DEFAULT_PAGE = 50;
/**
 * Firestore's hard ceiling on one document, in bytes. Not ours — the service's.
 *
 * @see https://cloud.google.com/firestore/quotas
 */
exports.FIRESTORE_MAX_DOCUMENT_BYTES = 1_048_576;
/**
 * The largest stored envelope this adapter will attempt, in bytes.
 *
 * Below the real ceiling by a margin, because the document also carries five
 * other fields, their NAMES, and the document's own path — all of which count
 * toward Firestore's total. Refusing a little early with a sentence that says
 * what to do beats sending a 1,048,570-byte envelope and getting back an
 * `INVALID_ARGUMENT` that names nothing.
 */
exports.FIRESTORE_MAX_ENVELOPE_BYTES = exports.FIRESTORE_MAX_DOCUMENT_BYTES - 8192;
/**
 * The domain string mixed into every document-name hash.
 *
 * Domain separation, so a `sessionId` and some other identifier that happened to
 * be the same string never produce the same digest anywhere else. It is version
 * -tagged: changing the hash would orphan every stored conversation, so the day
 * that has to happen the tag is what makes it a deliberate migration rather than
 * a silent one.
 */
const DOC_ID_DOMAIN = 'agentfootprint/hosting/firestoreSessions/v1';
// ─── The refusals ────────────────────────────────────────────────────
/**
 * Raised when the query `listByUser` needs has no composite index yet.
 *
 * Its own class rather than a generic failure, because this is the ONE
 * Firestore error an operator can fix in sixty seconds — and the only way they
 * will know that is if the message says which index, on which collection, in
 * which DATABASE, in which order. See the module header for the `gcloud` line.
 *
 * The database is named and the `--database` flag is always printed, including
 * for `(default)`, where gcloud would have assumed it anyway. That is deliberate:
 * a project may hold several Firestore databases, and an operator on a
 * non-default one who follows a command with no `--database` creates the index on
 * `(default)` and gets this identical error back. A refusal that teaches the
 * wrong fix is worse than a bare failure, and one always-present flag costs
 * nothing to be right.
 *
 * The service's own message carries a one-click creation link and is
 * deliberately NOT echoed: it restates the failing query, and the failing query
 * contains a user id. See {@link firestoreFailure}.
 */
class FirestoreIndexMissingError extends Error {
    code = 'ERR_FIRESTORE_INDEX_MISSING';
    /** The collection whose index is missing. */
    collection;
    /**
     * The database it lives in, or `undefined` when this store did not build the
     * client and therefore cannot know — see the message for what to do then.
     */
    database;
    constructor(collection, database) {
        // A placeholder that cannot be pasted blind, rather than a guess at
        // `(default)`. Guessing here is the exact failure this parameter exists to
        // stop, one layer further in.
        //
        // SINGLE-QUOTED, always: the default database is literally spelled
        // `(default)`, and bare parentheses are a syntax error in every shell an
        // operator will paste this into. A refusal that teaches the right fix in a
        // command that will not run is still the wrong refusal.
        const flagValue = `'${database ?? '<the database your Firestore client was built for>'}'`;
        super(`[hosting] ${ADAPTER}: listing a user's sessions needs a composite index on ` +
            `'${collection}' ` +
            (database === undefined
                ? `that does not exist yet, and Firestore refused the query (FAILED_PRECONDITION).\n`
                : `in database '${database}' that does not exist yet, and Firestore refused the ` +
                    `query (FAILED_PRECONDITION).\n`) +
            `  The index:  owner Ascending, savedAt Descending, __name__ Descending\n` +
            `  Create it:  gcloud firestore indexes composite create \\\n` +
            `                --collection-group=${collection} \\\n` +
            `                --field-config=field-path=owner,order=ascending \\\n` +
            `                --field-config=field-path=savedAt,order=descending \\\n` +
            `                --database=${flagValue}\n` +
            (database === undefined
                ? `  This store did not build the Firestore client, so it cannot name the ` +
                    `database — fill that flag in from wherever the client was constructed. ` +
                    `An index created on the wrong database leaves this error exactly as it is.\n`
                : ``) +
            `  An equality filter on one field ordered by another always needs one; ` +
            `Firestore's automatic single-field indexes do not serve that shape. ` +
            `Google's own error carries a console link that creates it in one click — it is ` +
            `withheld here because it restates the failing query, and the failing query ` +
            `contains a user id. Look in Cloud Logging for the original.`);
        this.name = 'FirestoreIndexMissingError';
        this.collection = collection;
        this.database = database;
    }
}
exports.FirestoreIndexMissingError = FirestoreIndexMissingError;
/**
 * Raised when a conversation is too big to be one Firestore document.
 *
 * Refused BEFORE the write, so the failure names the conversation and the fix
 * rather than arriving as an opaque `INVALID_ARGUMENT` from the wire. Nothing is
 * truncated on the way past: a conversation half-stored as if it were whole is
 * the exact failure this file's other laws exist to prevent.
 */
class EnvelopeTooLargeError extends Error {
    code = 'ERR_ENVELOPE_TOO_LARGE';
    /** The session that could not be stored. */
    sessionId;
    /** How big its serialized envelope was. */
    bytes;
    constructor(sessionId, bytes) {
        super(`[hosting] ${ADAPTER}: the conversation for session '${sessionId}' serializes to ` +
            `${bytes} bytes, and one Firestore document holds at most ` +
            `${exports.FIRESTORE_MAX_DOCUMENT_BYTES} (this store refuses above ${exports.FIRESTORE_MAX_ENVELOPE_BYTES}, ` +
            `leaving room for the document's other fields).\n` +
            `  Nothing was written and nothing was truncated — half a conversation stored as ` +
            `if it were whole is worse than a refusal you can see.\n` +
            `  Compact the conversation before persisting it, keep large payloads as artifacts ` +
            `rather than in the transcript, or use a store with no per-record ceiling.`);
        this.name = 'EnvelopeTooLargeError';
        this.sessionId = sessionId;
        this.bytes = bytes;
    }
}
exports.EnvelopeTooLargeError = EnvelopeTooLargeError;
// ─── The factory ─────────────────────────────────────────────────────
/**
 * Conversations in Firestore — a fleet-shared session store with no instance to
 * run.
 *
 * **Status: field-validated except the ownership refusal (2026-08).** An
 * independent field trial ran this adapter against a real Firestore and
 * exercised seven of its eight areas live — round trip, indexed and cursored
 * listing, the missing-index refusal, the hashed document name, the size
 * ceiling, derived ownership and `forget`. The eighth, the refusal of a turn
 * signed by somebody ELSE, was added after the trial and is held by tests
 * here and by nothing in the field. Every SDK member it calls was read off a
 * real install of `@google-cloud/firestore` 9.0.0 and hand-verified there; the
 * test that re-checks those names against the real package SKIPS in this
 * repository, because the package is deliberately not installed here. What
 * runs in CI is the dispatch pin. See the module header for the full account.
 *
 * @throws FirestoreIndexMissingError from `listByUser` until the composite index
 *   exists — see the module header for the exact index.
 * @throws EnvelopeTooLargeError from `persist` for a conversation above
 *   {@link FIRESTORE_MAX_ENVELOPE_BYTES}. Never truncated.
 *
 * @example  Conversations that expire 30 days after their last turn
 *   const sessions = firestoreSessions({
 *     project: 'my-project',
 *     expireAfterMs: 30 * 24 * 60 * 60 * 1000,
 *   });
 *   // Then, ONCE, as an operator — the store prints the exact command:
 *   console.log(sessions.retention().enableWith);
 *
 * @example  A standing agent whose conversations are shared across instances
 *   import { standingAgent, nodeHost } from 'agentfootprint/hosting';
 *   import { firestoreSessions } from 'agentfootprint/hosting';
 *
 *   const sessions = firestoreSessions({ project: 'my-project' });
 *   const handle = await standingAgent({
 *     agentFactory: () => buildAgent(),
 *     host: nodeHost({ port: 8080 }),
 *     sessions,
 *   });
 *   process.on('SIGTERM', () => void handle.close().then(() => sessions.close()));
 *
 * @example  Reusing the Firestore client the application already has
 *   const sessions = firestoreSessions({ firestore: db, collection: 'chat_sessions' });
 *   // close() will NOT terminate `db` — this store did not open it.
 */
function firestoreSessions(options = {}) {
    const collectionName = options.collection ?? exports.DEFAULT_SESSION_COLLECTION;
    assertCollectionName(collectionName);
    // A pre-built client and connection settings are mutually exclusive. Accepting
    // both would mean silently ignoring one of them, and the one silently ignored
    // is always the one the caller was relying on.
    if (options.firestore !== undefined &&
        (options.project !== undefined || options.database !== undefined)) {
        throw new TypeError(`${ADAPTER}: 'firestore' was given together with ` +
            `${options.project !== undefined ? "'project'" : "'database'"}. Those settings belong ` +
            `to whoever constructed the client, and this store cannot apply them to a client it ` +
            `did not build — so it refuses rather than accepting a connection option it will ` +
            `ignore. Pass the pre-built client alone, or let this store build one.`);
    }
    const sdk = options._sdk ?? loadFirestoreSdk();
    // Remembered, because close() must terminate only a client this store opened.
    const ownsClient = options.firestore === undefined;
    // Which database the queries run against — known exactly when this store built
    // the client (an omitted `database` IS `(default)`, and gcloud needs to be told
    // so explicitly), and `undefined` when the caller passed a client in, because
    // that setting belongs to whoever constructed it and cannot be read back off
    // the handle. The index refusal prints one or the other; it never guesses.
    const databaseName = ownsClient ? options.database ?? '(default)' : undefined;
    const db = options.firestore ?? buildClient(sdk, options);
    const documentIdSentinel = sdk.FieldPath.documentId();
    let closed = false;
    const open = (verb) => {
        if (!closed)
            return;
        throw new Error(`[hosting] the ${ADAPTER} store for '${collectionName}' is closed, so it cannot ${verb}. ` +
            `close() is final by design — reconnecting behind you would hide a shutdown-ordering ` +
            `bug rather than surface it. Build a new store if you need one after closing this.`);
    };
    const collection = () => db.collection(collectionName);
    const docFor = (sessionId) => collection().doc(documentIdFor(sessionId));
    /**
     * When this conversation should expire, or `null` for a store that was not
     * given a retention setting.
     *
     * Idle expiry, measured from the envelope's own `savedAt`: every turn moves
     * it, so a live conversation never expires underneath somebody.
     */
    const expiresAtFor = (savedAt) => options.expireAfterMs === undefined || !Number.isFinite(options.expireAfterMs)
        ? null
        : new Date(savedAt + options.expireAfterMs);
    /** The stored row, read back without trusting any of its types. */
    const readRow = async (sessionId, operation) => {
        let snapshot;
        try {
            snapshot = await docFor(sessionId).get();
        }
        catch (err) {
            throw firestoreFailure(operation, collectionName, err);
        }
        // `exists` is a PROPERTY on the real snapshot, and a missing document comes
        // back as a snapshot rather than as an error — so "no conversation" is read
        // here and never inferred from a failure.
        if (!snapshot.exists)
            return undefined;
        return snapshot.data();
    };
    return {
        collection: collectionName,
        documentIdFor,
        async hydrate(sessionId) {
            open('hydrate a session');
            const row = await readRow(sessionId, 'reading a session');
            if (row === undefined)
                return undefined;
            const stored = row['envelope'];
            if (typeof stored !== 'string') {
                // A document exists and its payload is not even text. Present,
                // unreadable — and specifically NOT `undefined`.
                throw new errors_js_1.UnreadableEnvelopeError(stored, sessionId);
            }
            let parsed;
            try {
                parsed = JSON.parse(stored);
            }
            catch {
                // Bytes written by something that was not this store. Same fact, same
                // refusal: a conversation EXISTS here and this runtime cannot see it.
                throw new errors_js_1.UnreadableEnvelopeError(stored, sessionId);
            }
            // Validated HERE as well as in the composer, so a refusal points at the
            // store that produced the bytes rather than at whoever read them next.
            return (0, envelope_js_1.checkEnvelope)(parsed, sessionId);
        },
        async persist(sessionId, envelope) {
            open('persist a session');
            // Checked on the way IN as well as out: a document this store could not
            // read back is one it has no business writing.
            const checked = (0, envelope_js_1.checkEnvelope)(envelope, sessionId);
            const json = JSON.stringify(checked);
            const bytes = Buffer.byteLength(json, 'utf8');
            if (bytes > exports.FIRESTORE_MAX_ENVELOPE_BYTES)
                throw new EnvelopeTooLargeError(sessionId, bytes);
            // The owner index: DERIVED from the conversation's own identity, never
            // supplied by the caller — the port takes no owner argument and gains
            // none, because a store where owning a session is a matter of asking is
            // not an index, it is a formality.
            const owner = (0, envelope_js_1.envelopeOwner)(checked);
            const ref = docFor(sessionId);
            const row = {
                sessionId,
                // The LISTED format (`listedFormatOf`), what `listByUser` reports: a
                // pause whose decision a resume already used reads as a conversation
                // here, though `envelope` still holds the paused run.
                format: (0, answeredPause_js_1.listedFormatOf)(checked),
                savedAt: checked.savedAt,
                envelope: json,
                messageCount: (0, envelope_js_1.envelopeTranscript)(checked).length,
                // The TTL field (9.42.0) — a SECOND field, never a change to
                // `savedAt`. See the module header: `savedAt` is the ordering key of
                // the composite index and the first half of every cursor this store
                // has ever minted, and Firestore sorts values by TYPE before value, so
                // making it a timestamp would have put every document written from now
                // on into a different sort bucket from every document already stored.
                //
                // A `Date`, which the client converts to a Timestamp on write — and a
                // TTL policy acts only on a Timestamp. Writing one this way rather
                // than through `Timestamp.fromMillis` is deliberate: it adds no member
                // to the SDK surface this adapter dispatches (so the pin row is
                // unchanged and no new name had to be hand-verified against a real
                // install), and it works identically for a caller who passed their own
                // client.
                //
                // `null` when nothing was configured, never `undefined`: the default
                // client throws on `undefined`, and a TTL policy ignores a field that
                // is null or missing — so an unconfigured store writes an inert field
                // rather than an absent one, exactly as `owner` does one line down and
                // for the same reason.
                [exports.EXPIRES_AT_FIELD]: expiresAtFor(checked.savedAt),
            };
            // WRITE ONCE for the owner, LAST WRITE WINS for everything else — the one
            // rule every store in this package shares, and since 9.36.1 the one
            // IMPLEMENTATION they share: `resolveSessionOwner`.
            //
            // An owner is a fact about the CONVERSATION, established by the first turn
            // that signed for it: a later turn carrying a leaner identity must not
            // erase it (the session would drop out of its owner's list), and a later
            // turn carrying a DIFFERENT one must not take it (ownership would transfer
            // by writing, which undoes every check made against this index one turn
            // later).
            //
            // A DIFFERENT one is REFUSED here rather than dropped, which is the
            // 9.36.1 correction. Keeping `owner` and writing the rest of the document
            // was this adapter's behaviour until then, and a live trial found what it
            // costs: two writers signed the same fresh session, both transactions
            // committed, and the surviving document named the FIRST writer in `owner`
            // and carried the SECOND writer's whole conversation in `envelope`. The
            // first writer listed it, opened it, and read the second writer's
            // conversation. `tx.set` is not reached at all now — the refusal leaves
            // the document exactly as it was.
            //
            // Firestore has no COALESCE, and `set({ merge: true })` is NOT a stand-in:
            // merge means "keep fields I did not mention", so mentioning `owner` at
            // all lets the last writer win, and NOT mentioning it means a conversation
            // that gained an identity on turn two never records one. Both are the bug.
            // The rule needs to READ the stored owner and then decide, and read-then-
            // decide-then-write is only safe inside a transaction — Firestore retries
            // the whole function when a concurrent write touched the document, so two
            // containers persisting the same turn cannot interleave into a lost owner.
            //
            // A full `set` (no merge) is deliberate for the rest: it REPLACES the
            // document, so a field written by an older version of this store does not
            // linger as a ghost beside the fields that replaced it.
            try {
                await db.runTransaction(async (tx) => {
                    // Every read in a Firestore transaction must precede every write. This
                    // one read is all there is, so that ordering is structural here rather
                    // than something to remember.
                    const snapshot = await tx.get(ref);
                    const existing = snapshot.exists ? snapshot.data() : undefined;
                    const existingOwner = existing?.['owner'];
                    // Throws SessionOwnershipConflictError on a different signer, out of
                    // the transaction function and therefore out of `runTransaction` —
                    // which is exactly the behaviour wanted: an aborted transaction
                    // writes nothing at all.
                    const keptOwner = (0, sessionOwnership_js_1.resolveSessionOwner)(sessionId, typeof existingOwner === 'string' ? existingOwner : undefined, owner);
                    // `null`, never `undefined`: an absent owner has to be a STORED fact,
                    // because the default client throws on `undefined` and because a field
                    // that is simply missing cannot be told apart from a field this store
                    // failed to write.
                    tx.set(ref, { ...row, owner: keptOwner ?? null });
                });
            }
            catch (err) {
                // OUTSIDE the wrapping on purpose — the same reason `awaitOperation`'s
                // refusal is left alone in the sibling adapter. This refusal is already
                // sanitized, already names the session and already says what to do;
                // re-describing it as a Firestore failure would replace a precise
                // diagnosis with "the backend said no" and imply the backend was at
                // fault, when the backend did exactly what it was told.
                if (err instanceof errors_js_1.SessionOwnershipConflictError)
                    throw err;
                throw firestoreFailure('persisting a session', collectionName, err);
            }
        },
        async listByUser(userId, listOptions) {
            open('list a user’s sessions');
            const limit = Math.max(1, Math.floor(listOptions?.limit ?? DEFAULT_PAGE));
            const after = parseCursor(listOptions?.cursor);
            // EVERY SDK call in this method is inside this one try, builders included.
            // The query builder is synchronous and it THROWS synchronously — a cursor
            // value the client will not accept (`startAfter` with a document name it
            // refuses) rejects from here, not from `get()`. Building outside the try
            // would let exactly one SDK error escape unsanitised, and it would be the
            // one a caller can trigger with a pagination token.
            let snapshot;
            try {
                // Server-side, indexed, and cursored — the three properties that make
                // this a listing rather than a scan. The alternative (read every document
                // for one owner, sort in the client, skip N) is correct exactly until
                // somebody has a lot of conversations, and then it reads all of them to
                // show ten.
                //
                // `__name__` is ordered explicitly rather than left to Firestore's
                // implicit tiebreak, for the same reason SQLite's listing orders by
                // `session_id` after `saved_at`: two conversations saved in the same
                // millisecond need a total order, or a cursor between them can skip one
                // or repeat one. It is the DOCUMENT NAME — the hash — so the tiebreak is
                // arbitrary but stable, which is all a tiebreak has to be.
                let query = collection()
                    .where('owner', '==', userId)
                    .orderBy('savedAt', 'desc')
                    .orderBy(documentIdSentinel, 'desc');
                if (after !== undefined) {
                    // A real Firestore cursor: the values of the ordered fields for the
                    // last row of the previous page. The SDK converts a bare document-name
                    // string into a full document reference for a `__name__` ordering —
                    // verified against the installed client, not assumed.
                    query = query.startAfter(after.savedAt, after.docId);
                }
                // One extra row, so "is there another page?" is a fact rather than a
                // guess from a full page.
                query = query.limit(limit + 1);
                snapshot = await query.get();
            }
            catch (err) {
                if (isFailedPrecondition(err)) {
                    throw new FirestoreIndexMissingError(collectionName, databaseName);
                }
                throw firestoreFailure('listing a user’s sessions', collectionName, err);
            }
            const docs = snapshot.docs.slice(0, limit);
            const sessions = docs.map((doc) => {
                const row = doc.data() ?? {};
                return {
                    // The RAW id out of the field, never the document name — the name is a
                    // hash and a caller has to be able to feed a listed id back to
                    // `hydrate`. A row written by something that is not this store may not
                    // carry one; an empty string is the honest answer for "this row does
                    // not say", and it is not silently swapped for the hash, which would
                    // hand a caller an id that opens nothing.
                    sessionId: typeof row['sessionId'] === 'string' ? row['sessionId'] : '',
                    savedAt: typeof row['savedAt'] === 'number' ? row['savedAt'] : 0,
                    // What the session IS now: a document written before the field
                    // held the listed format says 'flowchart-v1' for any pause, and is
                    // read again from the envelope the query already returned.
                    format: (0, answeredPause_js_1.listedFormatOfRow)(typeof row['format'] === 'string' ? row['format'] : 'unknown', row['envelope']),
                    // A listing hint, never the authority — the transcript op reads the
                    // envelope itself and says the truth.
                    messageCount: typeof row['messageCount'] === 'number' ? row['messageCount'] : 0,
                };
            });
            // The cursor is minted from the last document's RAW stored `savedAt`, not
            // from the summary above, and the difference is the whole point of doing it
            // in two lines instead of one.
            //
            // The two read the same field for different jobs. The SUMMARY is a display
            // hint that must never throw, so a row this store did not write reads back
            // as `0` and a sidebar still renders. The CURSOR is an ORDERING KEY, and it
            // has to be the value the server actually sorted by — `startAfter(0, …)` on
            // a descending listing positions past the end, so a summary-minted cursor
            // would hand back an empty second page and silently truncate somebody's
            // conversation list. The `?? 0` that makes the summary safe is precisely
            // what makes it wrong here.
            //
            // When that raw value is not a number, no `savedAt:docId` token can address
            // the position at all, and NO cursor is the honest answer: a caller that
            // gets none stops, where a caller handed `0:…` is told there is more and
            // then shown nothing. Both end the listing; only one of them lies about why.
            const last = docs[docs.length - 1];
            const lastSavedAt = last?.data()?.['savedAt'];
            return {
                sessions,
                ...(snapshot.docs.length > limit &&
                    last !== undefined &&
                    typeof lastSavedAt === 'number' && {
                    cursor: `${lastSavedAt}:${last.id}`,
                }),
            };
        },
        async ownerOf(sessionId) {
            open('read a session’s owner');
            const row = await readRow(sessionId, 'reading a session’s owner');
            const owner = row?.['owner'];
            // `undefined` for "no such session" AND for "a session nobody signed for"
            // — the deliberate ambiguity the composer's one not-found rests on. A store
            // that answered those differently would hand a caller an oracle for which
            // session ids are real.
            return typeof owner === 'string' && owner.length > 0 ? owner : undefined;
        },
        async forget(sessionId) {
            open('forget a session');
            try {
                // Deleting a document that is not there succeeds in Firestore, which is
                // the outcome this method asked for — so there is no not-found to catch.
                await docFor(sessionId).delete();
            }
            catch (err) {
                throw firestoreFailure('forgetting a session', collectionName, err);
            }
        },
        // ── Retention (9.42.0) ──────────────────────────────────────────────
        //
        // `'the-backend'`, and the honest answer rather than the convenient one.
        // This store COULD have offered a sweep — query the old documents, delete
        // them one at a time — and it would have been a worse answer at every
        // scale: a full index read plus one write per document, billed, repeated
        // nightly, to do a job the service already does for free and does not
        // charge a delete for. A port member exists so a caller can find out what
        // a store really does; a store that implemented the OTHER arm because it
        // was implementable would be answering a different question.
        //
        // It works on a closed store on purpose — it reads no document and calls
        // no SDK member. An operator asking "what expires these, and how do I turn
        // it on?" during a shutdown or an incident should get the answer, not the
        // refusal that guards the data plane.
        retention: () => ({
            deletedBy: 'the-backend',
            // Whether this store is really stamping the field, which is a different
            // fact from whether the mechanism exists. Reported rather than implied:
            // "there is a TTL field" and "conversations are expiring" are the two
            // things an auditor must not be allowed to confuse.
            active: options.expireAfterMs !== undefined && Number.isFinite(options.expireAfterMs),
            expiresOn: exports.EXPIRES_AT_FIELD,
            enableWith: `${ttlPolicyCommand(collectionName, databaseName)} — or Firestore console → ` +
                `Time-to-live (TTL) → Create policy, on collection '${collectionName}' and field ` +
                `'${exports.EXPIRES_AT_FIELD}'. The policy deletes a document within 24 hours of the ` +
                `instant in that field; it is not a hard deadline and the service says so. ` +
                `Documents stored before 'expireAfterMs' was configured carry no such field and ` +
                `are never deleted by it — they expire only once they are written again.`,
        }),
        async close() {
            if (closed)
                return;
            closed = true;
            if (!ownsClient)
                return;
            try {
                await db.terminate();
            }
            catch {
                // The store is closed either way. A failure to hand back gRPC channels
                // is not something a caller shutting down can act on, and a throw here
                // would turn an orderly shutdown into a crash over a released resource.
            }
        },
    };
}
exports.firestoreSessions = firestoreSessions;
// ─── Internals ───────────────────────────────────────────────────────
/**
 * The document name for one session id — `sha256(domain ‖ NUL ‖ id)` in hex.
 *
 * A module-level pure function rather than a closure, so the same mapping is
 * available to the store, to a test, and to an operator who needs it in a REPL.
 * The NUL separator is what stops `domain + "a" + "bc"` and `domain + "ab" + "c"`
 * from being the same input; a session id may legally contain anything else.
 *
 * See the module header for why this is an ADDRESSING scheme and not, in any
 * sense, encryption.
 */
function documentIdFor(sessionId) {
    return (0, node_crypto_1.createHash)('sha256').update(`${DOC_ID_DOMAIN}\u0000${sessionId}`, 'utf8').digest('hex');
}
exports.documentIdFor = documentIdFor;
/**
 * Load the peer dep, or refuse by name with the install line.
 *
 * `lazyRequire` keeps the specifier away from bundler static analysis, so
 * importing `agentfootprint/hosting` costs nothing for the consumers — the vast
 * majority — who never construct one of these.
 */
function loadFirestoreSdk() {
    let mod;
    try {
        mod = (0, lazyRequire_js_1.lazyRequire)('@google-cloud/firestore');
    }
    catch {
        throw new Error(`[hosting] ${ADAPTER} requires the \`@google-cloud/firestore\` package.\n` +
            `  Install:  npm install @google-cloud/firestore\n` +
            `  It is an OPTIONAL peer dependency, loaded only when you construct this store — ` +
            `memorySessions() and sqliteSessions() need nothing installed.`);
    }
    if (typeof mod.Firestore !== 'function' || typeof mod.FieldPath?.documentId !== 'function') {
        throw new Error(`[hosting] ${ADAPTER}: \`@google-cloud/firestore\` is installed but does not export ` +
            `both \`Firestore\` and \`FieldPath.documentId\`. This adapter is built against the ` +
            `9.x client — update the package, or pass \`firestore\` with a pre-built client.`);
    }
    return mod;
}
/**
 * Build the client.
 *
 * Only the settings this adapter has an opinion about are forwarded, and each is
 * omitted rather than passed as `undefined`: the client treats an explicit
 * `undefined` and an absent key the same way today, and relying on that is how a
 * connection ends up configured by an SDK upgrade.
 */
function buildClient(sdk, options) {
    return new sdk.Firestore({
        ...(options.project !== undefined && { projectId: options.project }),
        ...(options.database !== undefined && { databaseId: options.database }),
    });
}
/**
 * Refuse a collection name Firestore could not address.
 *
 * A `/` would make this a PATH — `db.collection('a/b')` is a document, and the
 * client throws a message about "an odd number of components" that says nothing
 * about the option the caller actually set. Catching it here names the option.
 */
function assertCollectionName(name) {
    const illegal = name.trim() === '' ||
        name.includes('/') ||
        name === '.' ||
        name === '..' ||
        /^__.*__$/.test(name);
    if (!illegal)
        return;
    throw new TypeError(`${ADAPTER}: 'collection' must be a single Firestore collection name, and ` +
        `${JSON.stringify(name)} is not one. It may not be empty, contain '/', be '.' or '..', ` +
        `or match '__…__' (Firestore reserves that spelling). Pass a plain name like ` +
        `'${exports.DEFAULT_SESSION_COLLECTION}'.`);
}
/**
 * The shape of every document name this store mints — {@link documentIdFor} is a
 * sha-256 in hex, so 64 lowercase hex characters, always.
 *
 * Both halves of a cursor are checked against what this store PRODUCES, not
 * against what Firestore would accept, and the difference matters: a document
 * name from another store may be perfectly legal for Firestore and still be a
 * position in a listing this one never took.
 */
const MINTED_DOCUMENT_NAME = /^[0-9a-f]{64}$/;
/**
 * Read a listing cursor — `<savedAt>:<documentName>`, the last row of the
 * previous page.
 *
 * A cursor this store did not mint (a truncation, a hand-edit, a client that
 * kept one across a release, a token minted by `sqliteSessions` whose ids may
 * legitimately be path-shaped) restarts at the top rather than throwing: the
 * worst case is a caller seeing page one twice, and refusing a listing because a
 * pagination token went stale would break a sidebar over something that costs
 * nothing to recover from.
 *
 * That tolerance is why BOTH halves are shape-checked here rather than only the
 * `savedAt`. A cursor is caller-supplied input, and an unrecognised document-name
 * half handed to `startAfter` makes the client throw SYNCHRONOUSLY — a
 * slash-containing name is rejected outright as "not a plain document ID" — so
 * the docstring's promise ("restarts at the top") would be broken by the one
 * input class it was written for. Checking the shape here keeps the promise, and
 * a foreign-looking cursor is FORGIVEN rather than refused for the same reason a
 * stale one is: page one twice is a cost nobody notices, and a refused listing is
 * an empty sidebar.
 */
function parseCursor(cursor) {
    if (cursor === undefined)
        return undefined;
    const at = cursor.indexOf(':');
    if (at <= 0)
        return undefined;
    // parseFLOAT, not parseInt: the cursor has to round-trip whatever number the
    // server ordered by, and `parseInt` would silently truncate a non-integer
    // `savedAt` into a position one row early.
    const savedAt = Number.parseFloat(cursor.slice(0, at));
    const docId = cursor.slice(at + 1);
    if (!Number.isFinite(savedAt) || !MINTED_DOCUMENT_NAME.test(docId))
        return undefined;
    return { savedAt, docId };
}
/**
 * gRPC status codes, by the numbers the client actually reports.
 *
 * **A Firestore error's `code` is a gRPC status, not an HTTP status**, and that
 * is precisely why this file does not reuse the Vertex column's `httpStatusOf` /
 * `googleSdkFailure`: those read `code` as HTTP, so a Firestore `NOT_FOUND`
 * would arrive as "HTTP 5" and a missing index as "HTTP 9". Two Google adapters,
 * two genuinely different error vocabularies — sharing the sanitizer would have
 * been a smaller file and a wrong one.
 *
 * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html
 */
const GRPC_STATUS = {
    0: 'OK',
    1: 'CANCELLED',
    2: 'UNKNOWN',
    3: 'INVALID_ARGUMENT',
    4: 'DEADLINE_EXCEEDED',
    5: 'NOT_FOUND',
    6: 'ALREADY_EXISTS',
    7: 'PERMISSION_DENIED',
    8: 'RESOURCE_EXHAUSTED',
    9: 'FAILED_PRECONDITION',
    10: 'ABORTED',
    11: 'OUT_OF_RANGE',
    12: 'UNIMPLEMENTED',
    13: 'INTERNAL',
    14: 'UNAVAILABLE',
    15: 'DATA_LOSS',
    16: 'UNAUTHENTICATED',
};
/**
 * The gRPC status of a failed call, as a NAME, wherever the client put it.
 *
 * Two spellings are accepted because two layers report it differently: the gax
 * layer sets a numeric `code`, and some wrappers carry the name as a string. A
 * classifier that read only one of them would quietly stop classifying the day
 * the client is upgraded.
 */
function grpcStatusOf(err) {
    const e = err;
    if (e === null || typeof e !== 'object')
        return undefined;
    for (const candidate of [e.code, e.status]) {
        if (typeof candidate === 'number' && GRPC_STATUS[candidate] !== undefined) {
            return GRPC_STATUS[candidate];
        }
        if (typeof candidate === 'string' && Object.values(GRPC_STATUS).includes(candidate)) {
            return candidate;
        }
    }
    return undefined;
}
exports.grpcStatusOf = grpcStatusOf;
/** Is this the service saying "that query has no index"? */
function isFailedPrecondition(err) {
    return grpcStatusOf(err) === 'FAILED_PRECONDITION';
}
exports.isFailedPrecondition = isFailedPrecondition;
/**
 * Re-raise a failed Firestore call **without its text** — the same law the AWS
 * and Vertex columns follow, re-aimed at a gRPC error.
 *
 * What comes through is the part that is both safe and actionable: which
 * operation failed, which collection it was on, and the gRPC status name. What
 * does not is the SDK's message, because a Firestore error restates the failing
 * request — a document path, a filter value, a field — and those carry a user id
 * and a whole conversation's state. An error thrown from an adapter reaches the
 * model as a tool result AND rides the event stream to every sink attached to
 * the agent.
 *
 * No credential is ever named. **The original is deliberately not attached as
 * `cause`** — a cause travels with the error into every serializer that walks
 * own properties, which would undo all of this in one `JSON.stringify`.
 */
function firestoreFailure(operation, collection, err) {
    const status = grpcStatusOf(err);
    const failure = new Error(`[hosting] ${ADAPTER}: ${operation} in collection '${collection}' failed` +
        (status === undefined ? '' : ` (${status})`) +
        `.\n  The SDK's own message is withheld: a Firestore error restates the failing ` +
        `request, and these requests carry a user id and a whole conversation's state. ` +
        `Check Cloud Logging for the full error.`);
    failure.name = 'FirestoreApiError';
    return failure;
}
exports.firestoreFailure = firestoreFailure;
//# sourceMappingURL=firestoreSessions.js.map