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
JavaScript
"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