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