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