agentfootprint 9.32.0 → 9.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,799 @@
1
+ /**
2
+ * firestoreSessions — conversations in Firestore, so a fleet shares them and
3
+ * nobody runs a database.
4
+ *
5
+ * The ladder this rung sits on is already in the package. `memorySessions()`
6
+ * loses everything on restart and says so. `sqliteSessions()` survives a restart
7
+ * on ONE machine and says so. `agentEngineSessions()` is a fleet store, but only
8
+ * for people who already own a Vertex reasoning engine. Firestore is the row for
9
+ * everyone else on this column: a serverless document database with no instance
10
+ * to size, no connection pool to tune, and a free tier — the plainest "many
11
+ * containers, one conversation" answer Google has.
12
+ *
13
+ * ── The shape, said plainly ─────────────────────────────────────────────────
14
+ * One collection. One document per session. Six fields:
15
+ *
16
+ * sessionId · format · savedAt · envelope · owner · messageCount
17
+ *
18
+ * That is the SQLite table, moved. It is deliberately the same shape, because
19
+ * the two stores implement the same port under the same laws, and a reader who
20
+ * has understood one should not have to learn a second model to audit the other.
21
+ *
22
+ * The envelope rides as a JSON **string**, not as a nested map, and that is a
23
+ * decision rather than laziness. A `CheckpointEnvelope` carries arbitrary
24
+ * conversation JSON: keys chosen by a model's tool call, values that may be
25
+ * `undefined`, arrays inside arrays. Firestore refuses all three — a field name
26
+ * may not contain a dot, a tilde, a star, a slash, a bracket or a backtick;
27
+ * `undefined` throws unless the client was
28
+ * built with `ignoreUndefinedProperties`, and a directly nested array is not a
29
+ * representable value. Serialising once at the edge makes every one of those a
30
+ * non-event, at the cost of not being able to query INSIDE a conversation —
31
+ * which no caller of this port has ever asked to do.
32
+ *
33
+ * ── Why the document id is a hash ───────────────────────────────────────────
34
+ * A `sessionId` in this library is OPAQUE. It may be a UUID, an upstream
35
+ * gateway's correlation id, a path-shaped tenant key, or a unicode string a
36
+ * person typed. Firestore document names have rules: no `/`, not `.` or `..`,
37
+ * not matching `__.*__`, and at most 1500 bytes. A session id that broke any of
38
+ * them would fail at the wire on the one turn it mattered — or worse, two ids
39
+ * that differ only past a truncation point would silently become ONE
40
+ * conversation.
41
+ *
42
+ * So the document name is `sha256(domain + NUL + sessionId)` in hex — 64
43
+ * characters, always legal, injective for every input anybody will ever have.
44
+ * The raw id is stored in the `sessionId` FIELD, so a listing can hand it back
45
+ * and a console reader can still see whose document they are looking at.
46
+ *
47
+ * **This is not encryption, and it is important not to read it as any.** The
48
+ * conversation itself is stored in the clear; the hash is an addressing scheme,
49
+ * not a confidentiality control. Anyone who can read the collection can read
50
+ * every conversation in it, and the `sessionId` field beside the hash spells out
51
+ * the id the hash was made from. Two things it DOES cost an operator, stated
52
+ * because they are discovered at the worst moment otherwise:
53
+ *
54
+ * • you cannot look a session up in the Firestore console by typing its raw
55
+ * id — you have to query `sessionId == '…'`, or hash it yourself;
56
+ * • a document name carries no information a human can sort or scan by.
57
+ *
58
+ * Encryption at rest is Google's (always on, and configurable with CMEK).
59
+ * Access control is IAM's. Neither is this adapter's, and neither is implied by
60
+ * the hash.
61
+ *
62
+ * ── The composite index, and the error you get without it ───────────────────
63
+ * `listByUser` runs one server-side query — an equality on `owner`, ordered by
64
+ * `savedAt` descending, paged with a real Firestore cursor. Firestore's
65
+ * automatic single-field indexes do NOT serve that shape: an equality filter on
66
+ * one field ordered by another needs a COMPOSITE index, and until it exists the
67
+ * query fails with gRPC status 9, `FAILED_PRECONDITION`.
68
+ *
69
+ * The index, exactly:
70
+ *
71
+ * collection group : <your collection> (default: agentfootprint_sessions)
72
+ * fields : owner Ascending
73
+ * savedAt Descending
74
+ * __name__ Descending
75
+ *
76
+ * `__name__` is Firestore's document-name field. It is the tiebreaker this
77
+ * adapter orders by explicitly (see {@link FirestoreSessions.listByUser}), and
78
+ * an index's trailing `__name__` takes the direction of the last ordered field —
79
+ * so a console-generated index for `owner ASC, savedAt DESC` is the right one.
80
+ *
81
+ * gcloud firestore indexes composite create \
82
+ * --collection-group=agentfootprint_sessions \
83
+ * --field-config=field-path=owner,order=ascending \
84
+ * --field-config=field-path=savedAt,order=descending \
85
+ * --database='(default)'
86
+ *
87
+ * `--database` is spelled out rather than left to gcloud's default, and it must
88
+ * match the `database` this store was built with. gcloud assumes `(default)` when
89
+ * the flag is absent, so an operator on a NAMED database who follows a command
90
+ * without it creates the index somewhere else and gets the identical failure
91
+ * back, with nothing to suggest why.
92
+ *
93
+ * When the index is missing this adapter raises {@link FirestoreIndexMissingError},
94
+ * which prints that same line with your collection and database already filled
95
+ * in, and names those fields rather than restating the service's message — see
96
+ * {@link firestoreFailure} for why no Google text is ever echoed here.
97
+ *
98
+ * ── The ceiling, since a store should name its own ──────────────────────────
99
+ * A Firestore document is capped at 1 MiB. A conversation whose stored envelope
100
+ * approaches that is refused BY NAME before the write
101
+ * ({@link EnvelopeTooLargeError}) rather than being sent and rejected as an
102
+ * opaque `INVALID_ARGUMENT`. Nothing is ever truncated: half a conversation
103
+ * stored as if it were whole is the failure this whole file exists to avoid.
104
+ * Compaction (`agent.compact()`) is the answer, and the refusal says so.
105
+ *
106
+ * ── The laws it inherits rather than re-implements ──────────────────────────
107
+ * `checkEnvelope` runs on the way OUT and on the way IN, so an envelope whose
108
+ * `format` this runtime does not know is refused by name, and a session that is
109
+ * PRESENT but unreadable is refused by name too. Only a session that was never
110
+ * written hydrates as `undefined`. A conversation that exists and cannot be read
111
+ * must never be answered with a fresh start — from the outside that is
112
+ * indistinguishable from a brand-new user.
113
+ *
114
+ * Ownership is DERIVED from the stored envelope and established ONCE. See
115
+ * `persist` below: SQLite gets that from `COALESCE(sessions.owner,
116
+ * excluded.owner)`, Firestore has no such thing, and a `set({ merge: true })`
117
+ * would let the last writer win — which is exactly the bug, not a workaround
118
+ * for it. So the write is a transaction.
119
+ *
120
+ * ── How much of this is verified ────────────────────────────────────────────
121
+ * **Contract-shaped and tested. NOT field-validated.** Nothing here has been run
122
+ * against a live Firestore by this repository.
123
+ *
124
+ * The pin is the `firestoreSessions` row of `GOOGLE_SURFACE_PINS` in
125
+ * `test/adapters/google/googlePin.ts`, asserted by
126
+ * `test/adapters/google/google-surface-pin.test.ts`. Its 18 members were
127
+ * hand-verified against a real `@google-cloud/firestore` 9.0.0 install in a
128
+ * scratch project OUTSIDE this repository: seventeen read off
129
+ * `types/firestore.d.ts` before a line of this file was written, and
130
+ * `DocumentSnapshot.id` verified afterwards, when a review found the row had
131
+ * pinned `DocumentReference.id` — real, but a member this adapter never reads —
132
+ * in place of the one the cursor actually reads.
133
+ *
134
+ * That package is deliberately NOT installed here. It depends on
135
+ * `@opentelemetry/api`, so installing it hoists that package to the repository
136
+ * root and disarms `test/observability-providers/otel.test.ts`, which proves
137
+ * `otelObservability()` refuses BY NAME when `@opentelemetry/api` is absent.
138
+ * The consequence has to be said plainly: **the reality assertion — "every
139
+ * pinned member really exists on the real package" — SKIPS in this repository.**
140
+ * It runs in full for anyone who installs `@google-cloud/firestore` locally.
141
+ *
142
+ * So what is machine-checked in CI is the SHAPE pin, not the reality pin: this
143
+ * adapter dispatches exactly the members the row names and no others, every run,
144
+ * everywhere. That the row spells those members the way Google does is held by a
145
+ * hand check against a real install, not by a test that runs here.
146
+ *
147
+ * The DESIGN is informed by an independent field trial of a different Firestore
148
+ * session adapter, which ran against a real Firestore and passed eight ownership
149
+ * and history checks — and whose own report named the defect this adapter does
150
+ * not reproduce: that adapter read every document for one owner, sorted them in
151
+ * the client, and applied an offset cursor. That works until one person has a
152
+ * lot of conversations, and then it costs a full read of all of them per page.
153
+ * What the trial proves is that the ownership and history SEMANTICS survive a
154
+ * real service; it proves nothing about this file's query, cursor, transaction
155
+ * or index, because that adapter had none of them.
156
+ */
157
+ import { createHash } from 'node:crypto';
158
+ import { checkEnvelope, envelopeOwner, envelopeTranscript } from '../../hosting/envelope.js';
159
+ import { UnreadableEnvelopeError } from '../../hosting/errors.js';
160
+ import { lazyRequire } from '../../lib/lazyRequire.js';
161
+ const ADAPTER = 'firestoreSessions';
162
+ /** Where sessions live when the caller names no collection. */
163
+ export const DEFAULT_SESSION_COLLECTION = 'agentfootprint_sessions';
164
+ /** How many rows one `listByUser` page carries when the caller names no limit. */
165
+ const DEFAULT_PAGE = 50;
166
+ /**
167
+ * Firestore's hard ceiling on one document, in bytes. Not ours — the service's.
168
+ *
169
+ * @see https://cloud.google.com/firestore/quotas
170
+ */
171
+ export const FIRESTORE_MAX_DOCUMENT_BYTES = 1_048_576;
172
+ /**
173
+ * The largest stored envelope this adapter will attempt, in bytes.
174
+ *
175
+ * Below the real ceiling by a margin, because the document also carries five
176
+ * other fields, their NAMES, and the document's own path — all of which count
177
+ * toward Firestore's total. Refusing a little early with a sentence that says
178
+ * what to do beats sending a 1,048,570-byte envelope and getting back an
179
+ * `INVALID_ARGUMENT` that names nothing.
180
+ */
181
+ export const FIRESTORE_MAX_ENVELOPE_BYTES = FIRESTORE_MAX_DOCUMENT_BYTES - 8192;
182
+ /**
183
+ * The domain string mixed into every document-name hash.
184
+ *
185
+ * Domain separation, so a `sessionId` and some other identifier that happened to
186
+ * be the same string never produce the same digest anywhere else. It is version
187
+ * -tagged: changing the hash would orphan every stored conversation, so the day
188
+ * that has to happen the tag is what makes it a deliberate migration rather than
189
+ * a silent one.
190
+ */
191
+ const DOC_ID_DOMAIN = 'agentfootprint/hosting/firestoreSessions/v1';
192
+ // ─── The refusals ────────────────────────────────────────────────────
193
+ /**
194
+ * Raised when the query `listByUser` needs has no composite index yet.
195
+ *
196
+ * Its own class rather than a generic failure, because this is the ONE
197
+ * Firestore error an operator can fix in sixty seconds — and the only way they
198
+ * will know that is if the message says which index, on which collection, in
199
+ * which DATABASE, in which order. See the module header for the `gcloud` line.
200
+ *
201
+ * The database is named and the `--database` flag is always printed, including
202
+ * for `(default)`, where gcloud would have assumed it anyway. That is deliberate:
203
+ * a project may hold several Firestore databases, and an operator on a
204
+ * non-default one who follows a command with no `--database` creates the index on
205
+ * `(default)` and gets this identical error back. A refusal that teaches the
206
+ * wrong fix is worse than a bare failure, and one always-present flag costs
207
+ * nothing to be right.
208
+ *
209
+ * The service's own message carries a one-click creation link and is
210
+ * deliberately NOT echoed: it restates the failing query, and the failing query
211
+ * contains a user id. See {@link firestoreFailure}.
212
+ */
213
+ export class FirestoreIndexMissingError extends Error {
214
+ code = 'ERR_FIRESTORE_INDEX_MISSING';
215
+ /** The collection whose index is missing. */
216
+ collection;
217
+ /**
218
+ * The database it lives in, or `undefined` when this store did not build the
219
+ * client and therefore cannot know — see the message for what to do then.
220
+ */
221
+ database;
222
+ constructor(collection, database) {
223
+ // A placeholder that cannot be pasted blind, rather than a guess at
224
+ // `(default)`. Guessing here is the exact failure this parameter exists to
225
+ // stop, one layer further in.
226
+ //
227
+ // SINGLE-QUOTED, always: the default database is literally spelled
228
+ // `(default)`, and bare parentheses are a syntax error in every shell an
229
+ // operator will paste this into. A refusal that teaches the right fix in a
230
+ // command that will not run is still the wrong refusal.
231
+ const flagValue = `'${database ?? '<the database your Firestore client was built for>'}'`;
232
+ super(`[hosting] ${ADAPTER}: listing a user's sessions needs a composite index on ` +
233
+ `'${collection}' ` +
234
+ (database === undefined
235
+ ? `that does not exist yet, and Firestore refused the query (FAILED_PRECONDITION).\n`
236
+ : `in database '${database}' that does not exist yet, and Firestore refused the ` +
237
+ `query (FAILED_PRECONDITION).\n`) +
238
+ ` The index: owner Ascending, savedAt Descending, __name__ Descending\n` +
239
+ ` Create it: gcloud firestore indexes composite create \\\n` +
240
+ ` --collection-group=${collection} \\\n` +
241
+ ` --field-config=field-path=owner,order=ascending \\\n` +
242
+ ` --field-config=field-path=savedAt,order=descending \\\n` +
243
+ ` --database=${flagValue}\n` +
244
+ (database === undefined
245
+ ? ` This store did not build the Firestore client, so it cannot name the ` +
246
+ `database — fill that flag in from wherever the client was constructed. ` +
247
+ `An index created on the wrong database leaves this error exactly as it is.\n`
248
+ : ``) +
249
+ ` An equality filter on one field ordered by another always needs one; ` +
250
+ `Firestore's automatic single-field indexes do not serve that shape. ` +
251
+ `Google's own error carries a console link that creates it in one click — it is ` +
252
+ `withheld here because it restates the failing query, and the failing query ` +
253
+ `contains a user id. Look in Cloud Logging for the original.`);
254
+ this.name = 'FirestoreIndexMissingError';
255
+ this.collection = collection;
256
+ this.database = database;
257
+ }
258
+ }
259
+ /**
260
+ * Raised when a conversation is too big to be one Firestore document.
261
+ *
262
+ * Refused BEFORE the write, so the failure names the conversation and the fix
263
+ * rather than arriving as an opaque `INVALID_ARGUMENT` from the wire. Nothing is
264
+ * truncated on the way past: a conversation half-stored as if it were whole is
265
+ * the exact failure this file's other laws exist to prevent.
266
+ */
267
+ export class EnvelopeTooLargeError extends Error {
268
+ code = 'ERR_ENVELOPE_TOO_LARGE';
269
+ /** The session that could not be stored. */
270
+ sessionId;
271
+ /** How big its serialized envelope was. */
272
+ bytes;
273
+ constructor(sessionId, bytes) {
274
+ super(`[hosting] ${ADAPTER}: the conversation for session '${sessionId}' serializes to ` +
275
+ `${bytes} bytes, and one Firestore document holds at most ` +
276
+ `${FIRESTORE_MAX_DOCUMENT_BYTES} (this store refuses above ${FIRESTORE_MAX_ENVELOPE_BYTES}, ` +
277
+ `leaving room for the document's other fields).\n` +
278
+ ` Nothing was written and nothing was truncated — half a conversation stored as ` +
279
+ `if it were whole is worse than a refusal you can see.\n` +
280
+ ` Compact the conversation before persisting it, keep large payloads as artifacts ` +
281
+ `rather than in the transcript, or use a store with no per-record ceiling.`);
282
+ this.name = 'EnvelopeTooLargeError';
283
+ this.sessionId = sessionId;
284
+ this.bytes = bytes;
285
+ }
286
+ }
287
+ // ─── The factory ─────────────────────────────────────────────────────
288
+ /**
289
+ * Conversations in Firestore — a fleet-shared session store with no instance to
290
+ * run.
291
+ *
292
+ * **Status: contract-shaped and tested, NOT field-validated.** Every SDK member
293
+ * it calls was read off a real install of `@google-cloud/firestore` 9.0.0 and
294
+ * hand-verified there; the test that re-checks those names against the real
295
+ * package SKIPS in this repository, because the package is deliberately not
296
+ * installed here. What runs in CI is the dispatch pin. No test here pretends to
297
+ * have reached Google. See the module header for the full account, and for what
298
+ * a field trial of a DIFFERENT adapter did and did not establish about this
299
+ * design.
300
+ *
301
+ * @throws FirestoreIndexMissingError from `listByUser` until the composite index
302
+ * exists — see the module header for the exact index.
303
+ * @throws EnvelopeTooLargeError from `persist` for a conversation above
304
+ * {@link FIRESTORE_MAX_ENVELOPE_BYTES}. Never truncated.
305
+ *
306
+ * @example A standing agent whose conversations are shared across instances
307
+ * import { standingAgent, nodeHost } from 'agentfootprint/hosting';
308
+ * import { firestoreSessions } from 'agentfootprint/hosting';
309
+ *
310
+ * const sessions = firestoreSessions({ project: 'my-project' });
311
+ * const handle = await standingAgent({
312
+ * agentFactory: () => buildAgent(),
313
+ * host: nodeHost({ port: 8080 }),
314
+ * sessions,
315
+ * });
316
+ * process.on('SIGTERM', () => void handle.close().then(() => sessions.close()));
317
+ *
318
+ * @example Reusing the Firestore client the application already has
319
+ * const sessions = firestoreSessions({ firestore: db, collection: 'chat_sessions' });
320
+ * // close() will NOT terminate `db` — this store did not open it.
321
+ */
322
+ export function firestoreSessions(options = {}) {
323
+ const collectionName = options.collection ?? DEFAULT_SESSION_COLLECTION;
324
+ assertCollectionName(collectionName);
325
+ // A pre-built client and connection settings are mutually exclusive. Accepting
326
+ // both would mean silently ignoring one of them, and the one silently ignored
327
+ // is always the one the caller was relying on.
328
+ if (options.firestore !== undefined &&
329
+ (options.project !== undefined || options.database !== undefined)) {
330
+ throw new TypeError(`${ADAPTER}: 'firestore' was given together with ` +
331
+ `${options.project !== undefined ? "'project'" : "'database'"}. Those settings belong ` +
332
+ `to whoever constructed the client, and this store cannot apply them to a client it ` +
333
+ `did not build — so it refuses rather than accepting a connection option it will ` +
334
+ `ignore. Pass the pre-built client alone, or let this store build one.`);
335
+ }
336
+ const sdk = options._sdk ?? loadFirestoreSdk();
337
+ // Remembered, because close() must terminate only a client this store opened.
338
+ const ownsClient = options.firestore === undefined;
339
+ // Which database the queries run against — known exactly when this store built
340
+ // the client (an omitted `database` IS `(default)`, and gcloud needs to be told
341
+ // so explicitly), and `undefined` when the caller passed a client in, because
342
+ // that setting belongs to whoever constructed it and cannot be read back off
343
+ // the handle. The index refusal prints one or the other; it never guesses.
344
+ const databaseName = ownsClient ? options.database ?? '(default)' : undefined;
345
+ const db = options.firestore ?? buildClient(sdk, options);
346
+ const documentIdSentinel = sdk.FieldPath.documentId();
347
+ let closed = false;
348
+ const open = (verb) => {
349
+ if (!closed)
350
+ return;
351
+ throw new Error(`[hosting] the ${ADAPTER} store for '${collectionName}' is closed, so it cannot ${verb}. ` +
352
+ `close() is final by design — reconnecting behind you would hide a shutdown-ordering ` +
353
+ `bug rather than surface it. Build a new store if you need one after closing this.`);
354
+ };
355
+ const collection = () => db.collection(collectionName);
356
+ const docFor = (sessionId) => collection().doc(documentIdFor(sessionId));
357
+ /** The stored row, read back without trusting any of its types. */
358
+ const readRow = async (sessionId, operation) => {
359
+ let snapshot;
360
+ try {
361
+ snapshot = await docFor(sessionId).get();
362
+ }
363
+ catch (err) {
364
+ throw firestoreFailure(operation, collectionName, err);
365
+ }
366
+ // `exists` is a PROPERTY on the real snapshot, and a missing document comes
367
+ // back as a snapshot rather than as an error — so "no conversation" is read
368
+ // here and never inferred from a failure.
369
+ if (!snapshot.exists)
370
+ return undefined;
371
+ return snapshot.data();
372
+ };
373
+ return {
374
+ collection: collectionName,
375
+ documentIdFor,
376
+ async hydrate(sessionId) {
377
+ open('hydrate a session');
378
+ const row = await readRow(sessionId, 'reading a session');
379
+ if (row === undefined)
380
+ return undefined;
381
+ const stored = row['envelope'];
382
+ if (typeof stored !== 'string') {
383
+ // A document exists and its payload is not even text. Present,
384
+ // unreadable — and specifically NOT `undefined`.
385
+ throw new UnreadableEnvelopeError(stored, sessionId);
386
+ }
387
+ let parsed;
388
+ try {
389
+ parsed = JSON.parse(stored);
390
+ }
391
+ catch {
392
+ // Bytes written by something that was not this store. Same fact, same
393
+ // refusal: a conversation EXISTS here and this runtime cannot see it.
394
+ throw new UnreadableEnvelopeError(stored, sessionId);
395
+ }
396
+ // Validated HERE as well as in the composer, so a refusal points at the
397
+ // store that produced the bytes rather than at whoever read them next.
398
+ return checkEnvelope(parsed, sessionId);
399
+ },
400
+ async persist(sessionId, envelope) {
401
+ open('persist a session');
402
+ // Checked on the way IN as well as out: a document this store could not
403
+ // read back is one it has no business writing.
404
+ const checked = checkEnvelope(envelope, sessionId);
405
+ const json = JSON.stringify(checked);
406
+ const bytes = Buffer.byteLength(json, 'utf8');
407
+ if (bytes > FIRESTORE_MAX_ENVELOPE_BYTES)
408
+ throw new EnvelopeTooLargeError(sessionId, bytes);
409
+ // The owner index: DERIVED from the conversation's own identity, never
410
+ // supplied by the caller — the port takes no owner argument and gains
411
+ // none, because a store where owning a session is a matter of asking is
412
+ // not an index, it is a formality.
413
+ const owner = envelopeOwner(checked);
414
+ const ref = docFor(sessionId);
415
+ const row = {
416
+ sessionId,
417
+ format: checked.format,
418
+ savedAt: checked.savedAt,
419
+ envelope: json,
420
+ messageCount: envelopeTranscript(checked).length,
421
+ };
422
+ // WRITE ONCE for the owner, LAST WRITE WINS for everything else — the same
423
+ // rule SQLite states as `owner = COALESCE(sessions.owner, excluded.owner)`.
424
+ //
425
+ // An owner is a fact about the CONVERSATION, established by the first turn
426
+ // that signed for it: a later turn carrying a leaner identity must not
427
+ // erase it (the session would drop out of its owner's list), and a later
428
+ // turn carrying a DIFFERENT one must not take it (ownership would transfer
429
+ // by writing, which undoes every check made against this index one turn
430
+ // later).
431
+ //
432
+ // Firestore has no COALESCE, and `set({ merge: true })` is NOT a stand-in:
433
+ // merge means "keep fields I did not mention", so mentioning `owner` at
434
+ // all lets the last writer win, and NOT mentioning it means a conversation
435
+ // that gained an identity on turn two never records one. Both are the bug.
436
+ // The rule needs to READ the stored owner and then decide, and read-then-
437
+ // decide-then-write is only safe inside a transaction — Firestore retries
438
+ // the whole function when a concurrent write touched the document, so two
439
+ // containers persisting the same turn cannot interleave into a lost owner.
440
+ //
441
+ // A full `set` (no merge) is deliberate for the rest: it REPLACES the
442
+ // document, so a field written by an older version of this store does not
443
+ // linger as a ghost beside the fields that replaced it.
444
+ try {
445
+ await db.runTransaction(async (tx) => {
446
+ // Every read in a Firestore transaction must precede every write. This
447
+ // one read is all there is, so that ordering is structural here rather
448
+ // than something to remember.
449
+ const snapshot = await tx.get(ref);
450
+ const existing = snapshot.exists ? snapshot.data() : undefined;
451
+ const existingOwner = existing?.['owner'];
452
+ const keptOwner = typeof existingOwner === 'string' && existingOwner.length > 0
453
+ ? existingOwner
454
+ : owner ?? null;
455
+ // `null`, never `undefined`: an absent owner has to be a STORED fact,
456
+ // because the default client throws on `undefined` and because a field
457
+ // that is simply missing cannot be told apart from a field this store
458
+ // failed to write.
459
+ tx.set(ref, { ...row, owner: keptOwner });
460
+ });
461
+ }
462
+ catch (err) {
463
+ throw firestoreFailure('persisting a session', collectionName, err);
464
+ }
465
+ },
466
+ async listByUser(userId, listOptions) {
467
+ open('list a user’s sessions');
468
+ const limit = Math.max(1, Math.floor(listOptions?.limit ?? DEFAULT_PAGE));
469
+ const after = parseCursor(listOptions?.cursor);
470
+ // EVERY SDK call in this method is inside this one try, builders included.
471
+ // The query builder is synchronous and it THROWS synchronously — a cursor
472
+ // value the client will not accept (`startAfter` with a document name it
473
+ // refuses) rejects from here, not from `get()`. Building outside the try
474
+ // would let exactly one SDK error escape unsanitised, and it would be the
475
+ // one a caller can trigger with a pagination token.
476
+ let snapshot;
477
+ try {
478
+ // Server-side, indexed, and cursored — the three properties that make
479
+ // this a listing rather than a scan. The alternative (read every document
480
+ // for one owner, sort in the client, skip N) is correct exactly until
481
+ // somebody has a lot of conversations, and then it reads all of them to
482
+ // show ten.
483
+ //
484
+ // `__name__` is ordered explicitly rather than left to Firestore's
485
+ // implicit tiebreak, for the same reason SQLite's listing orders by
486
+ // `session_id` after `saved_at`: two conversations saved in the same
487
+ // millisecond need a total order, or a cursor between them can skip one
488
+ // or repeat one. It is the DOCUMENT NAME — the hash — so the tiebreak is
489
+ // arbitrary but stable, which is all a tiebreak has to be.
490
+ let query = collection()
491
+ .where('owner', '==', userId)
492
+ .orderBy('savedAt', 'desc')
493
+ .orderBy(documentIdSentinel, 'desc');
494
+ if (after !== undefined) {
495
+ // A real Firestore cursor: the values of the ordered fields for the
496
+ // last row of the previous page. The SDK converts a bare document-name
497
+ // string into a full document reference for a `__name__` ordering —
498
+ // verified against the installed client, not assumed.
499
+ query = query.startAfter(after.savedAt, after.docId);
500
+ }
501
+ // One extra row, so "is there another page?" is a fact rather than a
502
+ // guess from a full page.
503
+ query = query.limit(limit + 1);
504
+ snapshot = await query.get();
505
+ }
506
+ catch (err) {
507
+ if (isFailedPrecondition(err)) {
508
+ throw new FirestoreIndexMissingError(collectionName, databaseName);
509
+ }
510
+ throw firestoreFailure('listing a user’s sessions', collectionName, err);
511
+ }
512
+ const docs = snapshot.docs.slice(0, limit);
513
+ const sessions = docs.map((doc) => {
514
+ const row = doc.data() ?? {};
515
+ return {
516
+ // The RAW id out of the field, never the document name — the name is a
517
+ // hash and a caller has to be able to feed a listed id back to
518
+ // `hydrate`. A row written by something that is not this store may not
519
+ // carry one; an empty string is the honest answer for "this row does
520
+ // not say", and it is not silently swapped for the hash, which would
521
+ // hand a caller an id that opens nothing.
522
+ sessionId: typeof row['sessionId'] === 'string' ? row['sessionId'] : '',
523
+ savedAt: typeof row['savedAt'] === 'number' ? row['savedAt'] : 0,
524
+ format: typeof row['format'] === 'string' ? row['format'] : 'unknown',
525
+ // A listing hint, never the authority — the transcript op reads the
526
+ // envelope itself and says the truth.
527
+ messageCount: typeof row['messageCount'] === 'number' ? row['messageCount'] : 0,
528
+ };
529
+ });
530
+ // The cursor is minted from the last document's RAW stored `savedAt`, not
531
+ // from the summary above, and the difference is the whole point of doing it
532
+ // in two lines instead of one.
533
+ //
534
+ // The two read the same field for different jobs. The SUMMARY is a display
535
+ // hint that must never throw, so a row this store did not write reads back
536
+ // as `0` and a sidebar still renders. The CURSOR is an ORDERING KEY, and it
537
+ // has to be the value the server actually sorted by — `startAfter(0, …)` on
538
+ // a descending listing positions past the end, so a summary-minted cursor
539
+ // would hand back an empty second page and silently truncate somebody's
540
+ // conversation list. The `?? 0` that makes the summary safe is precisely
541
+ // what makes it wrong here.
542
+ //
543
+ // When that raw value is not a number, no `savedAt:docId` token can address
544
+ // the position at all, and NO cursor is the honest answer: a caller that
545
+ // gets none stops, where a caller handed `0:…` is told there is more and
546
+ // then shown nothing. Both end the listing; only one of them lies about why.
547
+ const last = docs[docs.length - 1];
548
+ const lastSavedAt = last?.data()?.['savedAt'];
549
+ return {
550
+ sessions,
551
+ ...(snapshot.docs.length > limit &&
552
+ last !== undefined &&
553
+ typeof lastSavedAt === 'number' && {
554
+ cursor: `${lastSavedAt}:${last.id}`,
555
+ }),
556
+ };
557
+ },
558
+ async ownerOf(sessionId) {
559
+ open('read a session’s owner');
560
+ const row = await readRow(sessionId, 'reading a session’s owner');
561
+ const owner = row?.['owner'];
562
+ // `undefined` for "no such session" AND for "a session nobody signed for"
563
+ // — the deliberate ambiguity the composer's one not-found rests on. A store
564
+ // that answered those differently would hand a caller an oracle for which
565
+ // session ids are real.
566
+ return typeof owner === 'string' && owner.length > 0 ? owner : undefined;
567
+ },
568
+ async forget(sessionId) {
569
+ open('forget a session');
570
+ try {
571
+ // Deleting a document that is not there succeeds in Firestore, which is
572
+ // the outcome this method asked for — so there is no not-found to catch.
573
+ await docFor(sessionId).delete();
574
+ }
575
+ catch (err) {
576
+ throw firestoreFailure('forgetting a session', collectionName, err);
577
+ }
578
+ },
579
+ async close() {
580
+ if (closed)
581
+ return;
582
+ closed = true;
583
+ if (!ownsClient)
584
+ return;
585
+ try {
586
+ await db.terminate();
587
+ }
588
+ catch {
589
+ // The store is closed either way. A failure to hand back gRPC channels
590
+ // is not something a caller shutting down can act on, and a throw here
591
+ // would turn an orderly shutdown into a crash over a released resource.
592
+ }
593
+ },
594
+ };
595
+ }
596
+ // ─── Internals ───────────────────────────────────────────────────────
597
+ /**
598
+ * The document name for one session id — `sha256(domain ‖ NUL ‖ id)` in hex.
599
+ *
600
+ * A module-level pure function rather than a closure, so the same mapping is
601
+ * available to the store, to a test, and to an operator who needs it in a REPL.
602
+ * The NUL separator is what stops `domain + "a" + "bc"` and `domain + "ab" + "c"`
603
+ * from being the same input; a session id may legally contain anything else.
604
+ *
605
+ * See the module header for why this is an ADDRESSING scheme and not, in any
606
+ * sense, encryption.
607
+ */
608
+ export function documentIdFor(sessionId) {
609
+ return createHash('sha256').update(`${DOC_ID_DOMAIN}\u0000${sessionId}`, 'utf8').digest('hex');
610
+ }
611
+ /**
612
+ * Load the peer dep, or refuse by name with the install line.
613
+ *
614
+ * `lazyRequire` keeps the specifier away from bundler static analysis, so
615
+ * importing `agentfootprint/hosting` costs nothing for the consumers — the vast
616
+ * majority — who never construct one of these.
617
+ */
618
+ function loadFirestoreSdk() {
619
+ let mod;
620
+ try {
621
+ mod = lazyRequire('@google-cloud/firestore');
622
+ }
623
+ catch {
624
+ throw new Error(`[hosting] ${ADAPTER} requires the \`@google-cloud/firestore\` package.\n` +
625
+ ` Install: npm install @google-cloud/firestore\n` +
626
+ ` It is an OPTIONAL peer dependency, loaded only when you construct this store — ` +
627
+ `memorySessions() and sqliteSessions() need nothing installed.`);
628
+ }
629
+ if (typeof mod.Firestore !== 'function' || typeof mod.FieldPath?.documentId !== 'function') {
630
+ throw new Error(`[hosting] ${ADAPTER}: \`@google-cloud/firestore\` is installed but does not export ` +
631
+ `both \`Firestore\` and \`FieldPath.documentId\`. This adapter is built against the ` +
632
+ `9.x client — update the package, or pass \`firestore\` with a pre-built client.`);
633
+ }
634
+ return mod;
635
+ }
636
+ /**
637
+ * Build the client.
638
+ *
639
+ * Only the settings this adapter has an opinion about are forwarded, and each is
640
+ * omitted rather than passed as `undefined`: the client treats an explicit
641
+ * `undefined` and an absent key the same way today, and relying on that is how a
642
+ * connection ends up configured by an SDK upgrade.
643
+ */
644
+ function buildClient(sdk, options) {
645
+ return new sdk.Firestore({
646
+ ...(options.project !== undefined && { projectId: options.project }),
647
+ ...(options.database !== undefined && { databaseId: options.database }),
648
+ });
649
+ }
650
+ /**
651
+ * Refuse a collection name Firestore could not address.
652
+ *
653
+ * A `/` would make this a PATH — `db.collection('a/b')` is a document, and the
654
+ * client throws a message about "an odd number of components" that says nothing
655
+ * about the option the caller actually set. Catching it here names the option.
656
+ */
657
+ function assertCollectionName(name) {
658
+ const illegal = name.trim() === '' ||
659
+ name.includes('/') ||
660
+ name === '.' ||
661
+ name === '..' ||
662
+ /^__.*__$/.test(name);
663
+ if (!illegal)
664
+ return;
665
+ throw new TypeError(`${ADAPTER}: 'collection' must be a single Firestore collection name, and ` +
666
+ `${JSON.stringify(name)} is not one. It may not be empty, contain '/', be '.' or '..', ` +
667
+ `or match '__…__' (Firestore reserves that spelling). Pass a plain name like ` +
668
+ `'${DEFAULT_SESSION_COLLECTION}'.`);
669
+ }
670
+ /**
671
+ * The shape of every document name this store mints — {@link documentIdFor} is a
672
+ * sha-256 in hex, so 64 lowercase hex characters, always.
673
+ *
674
+ * Both halves of a cursor are checked against what this store PRODUCES, not
675
+ * against what Firestore would accept, and the difference matters: a document
676
+ * name from another store may be perfectly legal for Firestore and still be a
677
+ * position in a listing this one never took.
678
+ */
679
+ const MINTED_DOCUMENT_NAME = /^[0-9a-f]{64}$/;
680
+ /**
681
+ * Read a listing cursor — `<savedAt>:<documentName>`, the last row of the
682
+ * previous page.
683
+ *
684
+ * A cursor this store did not mint (a truncation, a hand-edit, a client that
685
+ * kept one across a release, a token minted by `sqliteSessions` whose ids may
686
+ * legitimately be path-shaped) restarts at the top rather than throwing: the
687
+ * worst case is a caller seeing page one twice, and refusing a listing because a
688
+ * pagination token went stale would break a sidebar over something that costs
689
+ * nothing to recover from.
690
+ *
691
+ * That tolerance is why BOTH halves are shape-checked here rather than only the
692
+ * `savedAt`. A cursor is caller-supplied input, and an unrecognised document-name
693
+ * half handed to `startAfter` makes the client throw SYNCHRONOUSLY — a
694
+ * slash-containing name is rejected outright as "not a plain document ID" — so
695
+ * the docstring's promise ("restarts at the top") would be broken by the one
696
+ * input class it was written for. Checking the shape here keeps the promise, and
697
+ * a foreign-looking cursor is FORGIVEN rather than refused for the same reason a
698
+ * stale one is: page one twice is a cost nobody notices, and a refused listing is
699
+ * an empty sidebar.
700
+ */
701
+ function parseCursor(cursor) {
702
+ if (cursor === undefined)
703
+ return undefined;
704
+ const at = cursor.indexOf(':');
705
+ if (at <= 0)
706
+ return undefined;
707
+ // parseFLOAT, not parseInt: the cursor has to round-trip whatever number the
708
+ // server ordered by, and `parseInt` would silently truncate a non-integer
709
+ // `savedAt` into a position one row early.
710
+ const savedAt = Number.parseFloat(cursor.slice(0, at));
711
+ const docId = cursor.slice(at + 1);
712
+ if (!Number.isFinite(savedAt) || !MINTED_DOCUMENT_NAME.test(docId))
713
+ return undefined;
714
+ return { savedAt, docId };
715
+ }
716
+ /**
717
+ * gRPC status codes, by the numbers the client actually reports.
718
+ *
719
+ * **A Firestore error's `code` is a gRPC status, not an HTTP status**, and that
720
+ * is precisely why this file does not reuse the Vertex column's `httpStatusOf` /
721
+ * `googleSdkFailure`: those read `code` as HTTP, so a Firestore `NOT_FOUND`
722
+ * would arrive as "HTTP 5" and a missing index as "HTTP 9". Two Google adapters,
723
+ * two genuinely different error vocabularies — sharing the sanitizer would have
724
+ * been a smaller file and a wrong one.
725
+ *
726
+ * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html
727
+ */
728
+ const GRPC_STATUS = {
729
+ 0: 'OK',
730
+ 1: 'CANCELLED',
731
+ 2: 'UNKNOWN',
732
+ 3: 'INVALID_ARGUMENT',
733
+ 4: 'DEADLINE_EXCEEDED',
734
+ 5: 'NOT_FOUND',
735
+ 6: 'ALREADY_EXISTS',
736
+ 7: 'PERMISSION_DENIED',
737
+ 8: 'RESOURCE_EXHAUSTED',
738
+ 9: 'FAILED_PRECONDITION',
739
+ 10: 'ABORTED',
740
+ 11: 'OUT_OF_RANGE',
741
+ 12: 'UNIMPLEMENTED',
742
+ 13: 'INTERNAL',
743
+ 14: 'UNAVAILABLE',
744
+ 15: 'DATA_LOSS',
745
+ 16: 'UNAUTHENTICATED',
746
+ };
747
+ /**
748
+ * The gRPC status of a failed call, as a NAME, wherever the client put it.
749
+ *
750
+ * Two spellings are accepted because two layers report it differently: the gax
751
+ * layer sets a numeric `code`, and some wrappers carry the name as a string. A
752
+ * classifier that read only one of them would quietly stop classifying the day
753
+ * the client is upgraded.
754
+ */
755
+ export function grpcStatusOf(err) {
756
+ const e = err;
757
+ if (e === null || typeof e !== 'object')
758
+ return undefined;
759
+ for (const candidate of [e.code, e.status]) {
760
+ if (typeof candidate === 'number' && GRPC_STATUS[candidate] !== undefined) {
761
+ return GRPC_STATUS[candidate];
762
+ }
763
+ if (typeof candidate === 'string' && Object.values(GRPC_STATUS).includes(candidate)) {
764
+ return candidate;
765
+ }
766
+ }
767
+ return undefined;
768
+ }
769
+ /** Is this the service saying "that query has no index"? */
770
+ export function isFailedPrecondition(err) {
771
+ return grpcStatusOf(err) === 'FAILED_PRECONDITION';
772
+ }
773
+ /**
774
+ * Re-raise a failed Firestore call **without its text** — the same law the AWS
775
+ * and Vertex columns follow, re-aimed at a gRPC error.
776
+ *
777
+ * What comes through is the part that is both safe and actionable: which
778
+ * operation failed, which collection it was on, and the gRPC status name. What
779
+ * does not is the SDK's message, because a Firestore error restates the failing
780
+ * request — a document path, a filter value, a field — and those carry a user id
781
+ * and a whole conversation's state. An error thrown from an adapter reaches the
782
+ * model as a tool result AND rides the event stream to every sink attached to
783
+ * the agent.
784
+ *
785
+ * No credential is ever named. **The original is deliberately not attached as
786
+ * `cause`** — a cause travels with the error into every serializer that walks
787
+ * own properties, which would undo all of this in one `JSON.stringify`.
788
+ */
789
+ export function firestoreFailure(operation, collection, err) {
790
+ const status = grpcStatusOf(err);
791
+ const failure = new Error(`[hosting] ${ADAPTER}: ${operation} in collection '${collection}' failed` +
792
+ (status === undefined ? '' : ` (${status})`) +
793
+ `.\n The SDK's own message is withheld: a Firestore error restates the failing ` +
794
+ `request, and these requests carry a user id and a whole conversation's state. ` +
795
+ `Check Cloud Logging for the full error.`);
796
+ failure.name = 'FirestoreApiError';
797
+ return failure;
798
+ }
799
+ //# sourceMappingURL=firestoreSessions.js.map