@guuey/threads 0.31.0 → 0.32.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,274 @@
1
+ /**
2
+ * Readers for what the hosted thread API answers ({@link HttpThreadPersistence}
3
+ * in `http.ts`). A response body is parsed to `unknown` and read here member by
4
+ * member; nothing is taken as a port type until these readers have proven it.
5
+ *
6
+ * Each reader returns the typed value, or `undefined` when the payload is not
7
+ * one; the binding turns `undefined` into an `HttpThreadStoreError` with code
8
+ * `BAD_RESPONSE`, the error every caller of the binding already handles.
9
+ *
10
+ * What a reader checks follows the port types in `rows.ts`, read across a
11
+ * rolling release, and reads a stored row the way the DynamoDB binding hands
12
+ * the same row to its readers (the two bindings must agree):
13
+ * - a member that IDENTIFIES the row (a thread's own members; a message's
14
+ * `threadId`, integer `seq`, `clientMessageId`, `at`, which the schema
15
+ * requires on every row) is proven by type, and a row without it is refused;
16
+ * - a message's closed vocabularies are read the way every reader of the row
17
+ * reads them: a `kind` this release does not know (a newer writer's, or none
18
+ * on a legacy row) is a `text` row (it is not a card, the one kind a reader
19
+ * branches on), and an `authorRole` it does not know is `agent` (not the
20
+ * user's words: `rowToAgMessage` reads it as the assistant, the managed read
21
+ * plane as `agent`), so one such row never refuses the thread's history;
22
+ * - a message's `userId` (no reader on this path reads it) is left absent when
23
+ * a legacy row has none, and the binding fills in the token's user, the only
24
+ * user whose threads the server answers (`http.ts`);
25
+ * - an OPTIONAL member is kept when it reads as its type and is absent
26
+ * otherwise, the way each one's own reader treats it at use (`readThreadInputKind`,
27
+ * `readTapLabels`, `threadClosure`), so a field an older server omits reads
28
+ * as absent and a value a newer one adds that this release does not know is
29
+ * left out;
30
+ * - a thread's `sealIntervals` is carried as stored, never normalised or
31
+ * dropped: `readSealState` reads it at use, and reads one it cannot read as
32
+ * wholly sealed (fail closed), exactly as over the DynamoDB binding;
33
+ * - a message's `untrustedOrigin` that is not a boolean would read as trusted
34
+ * if it were dropped, so that row is refused instead;
35
+ * - opaque members (`content`, `cardSnapshot`, `aiContext`) are carried as
36
+ * they came, as the port types them (`unknown`).
37
+ */
38
+ import { JsonValue } from "@silverprotocol/core";
39
+ import { readTapLabels } from "@guuey/mcp-apps-host/narrowing";
40
+ import { splitStoredThreadMemory } from "./fold-rows.js";
41
+ import { readThreadInputKind, } from "./rows.js";
42
+ /** Whether `value` is a JSON object: not `null`, not an array, not a scalar. */
43
+ export function isWireObject(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+ function isSeqNumber(value) {
47
+ return typeof value === "number" && Number.isInteger(value);
48
+ }
49
+ /** The member when it is a string, else undefined (absent, null or another type). */
50
+ function optString(obj, key) {
51
+ const value = obj[key];
52
+ return typeof value === "string" ? value : undefined;
53
+ }
54
+ const MESSAGE_KINDS = ["text", "card", "event"];
55
+ const MESSAGE_ROLES = ["user", "agent", "system"];
56
+ /** What a message `kind` this release does not know reads as (see the module doc). */
57
+ const UNKNOWN_KIND_READS_AS = "text";
58
+ /** What a message `authorRole` this release does not know reads as (see the module doc). */
59
+ const UNKNOWN_ROLE_READS_AS = "agent";
60
+ const TURN_ORIGINS = ["card_action", "chip", "typed"];
61
+ const CLIENT_CLASSES = ["operator", "bot", "browser"];
62
+ /** `value` when it is one of `allowed`, else undefined. */
63
+ function oneOf(allowed, value) {
64
+ return allowed.find((member) => member === value);
65
+ }
66
+ /** `scope`'s result: the three strings the server binds the token to. */
67
+ export function readThreadScope(value) {
68
+ if (!isWireObject(value))
69
+ return undefined;
70
+ const { appId, userId, region } = value;
71
+ if (typeof appId !== "string" || typeof userId !== "string" || typeof region !== "string")
72
+ return undefined;
73
+ return { appId, userId, region };
74
+ }
75
+ /** `incrementSeq`'s result: the integer seq the server allocated. */
76
+ export function readAllocatedSeq(value) {
77
+ return isSeqNumber(value) ? value : undefined;
78
+ }
79
+ /** A `getThread` row. */
80
+ export function readThreadRow(value) {
81
+ if (!isWireObject(value))
82
+ return undefined;
83
+ const { id, userId, appId, servingRegion, title, status, pinned, lastSeq } = value;
84
+ const { lastMessageAt, lastMessagePreview, threadMode, createdAt, updatedAt } = value;
85
+ if (typeof id !== "string" ||
86
+ typeof userId !== "string" ||
87
+ typeof appId !== "string" ||
88
+ typeof servingRegion !== "string" ||
89
+ typeof title !== "string" ||
90
+ typeof status !== "string" ||
91
+ typeof pinned !== "boolean" ||
92
+ !isSeqNumber(lastSeq) ||
93
+ typeof lastMessageAt !== "string" ||
94
+ typeof lastMessagePreview !== "string" ||
95
+ typeof threadMode !== "string" ||
96
+ typeof createdAt !== "string" ||
97
+ typeof updatedAt !== "string") {
98
+ return undefined;
99
+ }
100
+ // Carried as stored (`null` is no seal, as `readSealState` reads it): a seal
101
+ // this release cannot read still reaches `readSealState`, which reads it as
102
+ // wholly sealed, so the thread stays usable and closed.
103
+ const sealIntervals = value.sealIntervals;
104
+ const previewSeq = value.lastMessagePreviewSeq;
105
+ const previewDigest = optString(value, "lastMessagePreviewDigest");
106
+ const closedCode = optString(value, "closedCode");
107
+ const closedMessage = optString(value, "closedMessage");
108
+ const closedAt = optString(value, "closedAt");
109
+ return {
110
+ id,
111
+ userId,
112
+ appId,
113
+ servingRegion,
114
+ title,
115
+ status,
116
+ pinned,
117
+ lastSeq,
118
+ lastMessageAt,
119
+ lastMessagePreview,
120
+ ...(isSeqNumber(previewSeq) ? { lastMessagePreviewSeq: previewSeq } : {}),
121
+ ...(previewDigest !== undefined ? { lastMessagePreviewDigest: previewDigest } : {}),
122
+ ...(sealIntervals !== undefined && sealIntervals !== null ? { sealIntervals } : {}),
123
+ ...(closedCode !== undefined ? { closedCode: closedCode } : {}),
124
+ ...(closedMessage !== undefined ? { closedMessage: closedMessage } : {}),
125
+ ...(closedAt !== undefined ? { closedAt: closedAt } : {}),
126
+ threadMode,
127
+ createdAt,
128
+ updatedAt,
129
+ };
130
+ }
131
+ /** A stored `event` this release reads; another `type`, or a malformed one, is not one ("not mine"). */
132
+ function readThreadMessageEvent(value) {
133
+ if (!isWireObject(value))
134
+ return undefined;
135
+ if (value.type === "handoff") {
136
+ const question = value.question;
137
+ if (typeof question !== "string")
138
+ return undefined;
139
+ const contactEmail = optString(value, "contactEmail");
140
+ const contactName = optString(value, "contactName");
141
+ const summary = optString(value, "summary");
142
+ const event = {
143
+ type: "handoff",
144
+ question,
145
+ ...(contactEmail !== undefined ? { contactEmail } : {}),
146
+ ...(contactName !== undefined ? { contactName } : {}),
147
+ ...(summary !== undefined ? { summary } : {}),
148
+ };
149
+ return event;
150
+ }
151
+ if (value.type === "report") {
152
+ const { summary, reportedBy, wantedHuman } = value;
153
+ if (typeof summary !== "string" || typeof reportedBy !== "string")
154
+ return undefined;
155
+ const contactEmail = optString(value, "contactEmail");
156
+ const contactName = optString(value, "contactName");
157
+ const event = {
158
+ type: "report",
159
+ summary,
160
+ ...(typeof wantedHuman === "boolean" ? { wantedHuman } : {}),
161
+ ...(contactEmail !== undefined ? { contactEmail } : {}),
162
+ ...(contactName !== undefined ? { contactName } : {}),
163
+ reportedBy,
164
+ };
165
+ return event;
166
+ }
167
+ return undefined;
168
+ }
169
+ /** A message row (`listRecentMessages`, `findByClientMessageId`). */
170
+ export function readThreadMessageRow(value) {
171
+ if (!isWireObject(value))
172
+ return undefined;
173
+ const { threadId, seq, clientMessageId, at } = value;
174
+ if (typeof threadId !== "string" || !isSeqNumber(seq) || typeof clientMessageId !== "string" || typeof at !== "string") {
175
+ return undefined;
176
+ }
177
+ const userId = optString(value, "userId");
178
+ const kind = oneOf(MESSAGE_KINDS, value.kind) ?? UNKNOWN_KIND_READS_AS;
179
+ const authorRole = oneOf(MESSAGE_ROLES, value.authorRole) ?? UNKNOWN_ROLE_READS_AS;
180
+ // `untrustedOrigin` marks page-derived content; a stored value that is not a
181
+ // boolean must not read as trusted, so the row is refused.
182
+ const untrustedOrigin = value.untrustedOrigin;
183
+ if (untrustedOrigin !== undefined && typeof untrustedOrigin !== "boolean")
184
+ return undefined;
185
+ const event = readThreadMessageEvent(value.event);
186
+ const turnOrigin = oneOf(TURN_ORIGINS, value.turnOrigin);
187
+ const clientClass = oneOf(CLIENT_CLASSES, value.clientClass);
188
+ const inputKind = readThreadInputKind(value.inputKind);
189
+ const tapLabels = readTapLabels(value.tapLabels);
190
+ const text = optString(value, "text");
191
+ const toolName = optString(value, "toolName");
192
+ const answeredCardSessionId = optString(value, "answeredCardSessionId");
193
+ return {
194
+ threadId,
195
+ seq,
196
+ ...(userId !== undefined ? { userId } : {}),
197
+ clientMessageId,
198
+ at,
199
+ kind,
200
+ authorRole,
201
+ ...(text !== undefined ? { text } : {}),
202
+ ...(value.content !== undefined ? { content: value.content } : {}),
203
+ ...(value.cardSnapshot !== undefined ? { cardSnapshot: value.cardSnapshot } : {}),
204
+ ...(toolName !== undefined ? { toolName } : {}),
205
+ ...(value.aiContext !== undefined ? { aiContext: value.aiContext } : {}),
206
+ ...(untrustedOrigin !== undefined ? { untrustedOrigin } : {}),
207
+ ...(event !== undefined ? { event } : {}),
208
+ ...(turnOrigin !== undefined ? { turnOrigin } : {}),
209
+ ...(inputKind !== undefined ? { inputKind } : {}),
210
+ ...(answeredCardSessionId !== undefined ? { answeredCardSessionId } : {}),
211
+ ...(clientClass !== undefined ? { clientClass } : {}),
212
+ ...(tapLabels !== undefined ? { tapLabels } : {}),
213
+ };
214
+ }
215
+ /**
216
+ * `listRecentMessages`' result: every row must read, or the list is refused. A
217
+ * row reads unless it lacks a member that identifies it or carries a malformed
218
+ * `untrustedOrigin` (see the module doc); a vocabulary value or a `userId` this
219
+ * release does not know never refuses it.
220
+ */
221
+ export function readThreadMessageRows(value) {
222
+ if (!Array.isArray(value))
223
+ return undefined;
224
+ const rows = [];
225
+ for (const entry of value) {
226
+ const row = readThreadMessageRow(entry);
227
+ if (row === undefined)
228
+ return undefined;
229
+ rows.push(row);
230
+ }
231
+ return rows;
232
+ }
233
+ /**
234
+ * A `getSnapshot` row. Its memory arrays are JSON (checked, never assumed); the
235
+ * thread records are re-read through the one stored-memory reader
236
+ * (`splitStoredThreadMemory`), and any element this release cannot read as a
237
+ * thread record joins the carried elements, so it is kept, never dropped.
238
+ */
239
+ export function readThreadSnapshotRow(value) {
240
+ if (!isWireObject(value))
241
+ return undefined;
242
+ const { threadId, userId, updatedAt } = value;
243
+ if (typeof threadId !== "string" || typeof userId !== "string" || typeof updatedAt !== "string")
244
+ return undefined;
245
+ const memory = JsonValue.array().safeParse(value.threadMemory);
246
+ if (!memory.success)
247
+ return undefined;
248
+ let carried = [];
249
+ if (value.carriedThreadMemory !== undefined) {
250
+ const parsed = JsonValue.array().safeParse(value.carriedThreadMemory);
251
+ if (!parsed.success)
252
+ return undefined;
253
+ carried = parsed.data;
254
+ }
255
+ let workingState;
256
+ if (value.workingState !== undefined) {
257
+ const parsed = JsonValue.safeParse(value.workingState);
258
+ if (!parsed.success)
259
+ return undefined;
260
+ workingState = parsed.data;
261
+ }
262
+ const split = splitStoredThreadMemory(memory.data);
263
+ const carriedThreadMemory = [...carried, ...split.carriedThreadMemory];
264
+ const lastTurnId = optString(value, "lastTurnId");
265
+ return {
266
+ threadId,
267
+ userId,
268
+ ...(workingState !== undefined ? { workingState } : {}),
269
+ threadMemory: split.threadMemory,
270
+ ...(value.carriedThreadMemory !== undefined || carriedThreadMemory.length > 0 ? { carriedThreadMemory } : {}),
271
+ ...(lastTurnId !== undefined ? { lastTurnId } : {}),
272
+ updatedAt,
273
+ };
274
+ }
package/dist/http.d.ts CHANGED
@@ -1,33 +1,4 @@
1
- /**
2
- * Hosted HTTP binding — {@link ThreadPersistencePort} over guuey's thread
3
- * API, `POST <base>/v1/thread-store/<op>` (guuey#208). The "eject the code,
4
- * keep your memory" leg of the ejected ladder: point a `ThreadStore` at
5
- * this binding with an end-user token and your self-hosted agent reads and
6
- * writes the SAME conversation rows guuey's hosted runtime does.
7
- *
8
- * Wire protocol (the `@guuey/state` `HttpKv` shape):
9
- * - Request: JSON body `{ context: { appId }, args: {...} }`, header
10
- * `Authorization: Bearer <token>`.
11
- * - Success: `200 { result }`.
12
- * - Failure: `4xx/5xx { code, message }` → {@link HttpThreadStoreError}.
13
- *
14
- * Auth + tenancy: the token is an END-USER token for the app — one the
15
- * app's configured identity issuer minted (your own IdP, or guuey's per-app
16
- * widget issuer via `@guuey/widget-auth`). The server verifies it against
17
- * that issuer, derives the same `byo_…` userId the hosted runtime would, and
18
- * scopes every op to `(appId, userId)`: rows you write must carry that
19
- * userId, threads you touch must belong to it. `context` on the wire is
20
- * advisory — the token is authoritative. Call {@link scope} to learn the
21
- * derived userId (and the API's region) before `ensureThread`.
22
- *
23
- * Retry policy: reads (`getThread`/`listRecentMessages`/
24
- * `findByClientMessageId`/`getSnapshot`/`scope`) are idempotent and retried
25
- * once on a network failure or a 5xx. Writes (`createThread`/`incrementSeq`/
26
- * `putMessage`/`putSnapshot`) are NEVER retried automatically —
27
- * `incrementSeq` is not idempotent, and a blind retry after a server-side
28
- * apply would double-allocate a seq.
29
- */
30
- import type { ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow } from "./rows.js";
1
+ import type { RowBudget, ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow } from "./rows.js";
31
2
  /** What the token resolves to server-side — the scope every op is bound to. */
32
3
  export interface ThreadScope {
33
4
  appId: string;
@@ -50,7 +21,9 @@ export interface HttpThreadPersistenceOptions {
50
21
  * Thrown for every non-2xx response and every exhausted network failure.
51
22
  * `code` is the server's flat error code (`UNAUTHORIZED`, `FORBIDDEN`,
52
23
  * `INVALID_ARGUMENT`, `INVALID_CONTEXT`, `THREAD_NOT_FOUND`, `CONFLICT`,
53
- * `NOT_FOUND`, `TRANSPORT`); `status` is the HTTP status (`0` for a network
24
+ * `NOT_FOUND`, `TRANSPORT`, and the client's own `BAD_RESPONSE` for a 2xx body
25
+ * it cannot read; `incrementSeq`'s `THREAD_CLOSED` is thrown as
26
+ * `ThreadClosedError` instead); `status` is the HTTP status (`0` for a network
54
27
  * failure that never produced a response).
55
28
  */
56
29
  export declare class HttpThreadStoreError extends Error {
@@ -61,10 +34,17 @@ export declare class HttpThreadStoreError extends Error {
61
34
  });
62
35
  }
63
36
  export declare class HttpThreadPersistence implements ThreadPersistencePort {
37
+ /**
38
+ * guuey#1794: the server stores the rows this binding sends in DynamoDB, so
39
+ * the store sizes them by DynamoDB's own item rules before it sends any.
40
+ */
41
+ readonly rowBudget: RowBudget;
64
42
  private readonly baseUrl;
65
43
  private readonly appId;
66
44
  private readonly token;
67
45
  private readonly fetchImpl;
46
+ /** The token's user, read from `scope` the first time a row lacks its `userId`. */
47
+ private ownerRead;
68
48
  constructor(opts: HttpThreadPersistenceOptions);
69
49
  /** The `(appId, userId, region)` the server bound this token to. */
70
50
  scope(): Promise<ThreadScope>;
@@ -77,10 +57,33 @@ export declare class HttpThreadPersistence implements ThreadPersistencePort {
77
57
  getSnapshot(threadId: string): Promise<ThreadSnapshotRow | undefined>;
78
58
  putSnapshot(row: ThreadSnapshotRow): Promise<void>;
79
59
  /**
80
- * One shared retry budget (`retried`) covers BOTH retry triggers — a
81
- * network failure and a 5xx response — so a retryable op makes at most
82
- * 2 total requests, never 3.
60
+ * A write: never retried, and its body (the server's `{ result: null }`) is
61
+ * never read: a 2xx means it committed, whatever the body holds. The body is
62
+ * cancelled, not left open, so the connection is released.
63
+ *
64
+ * Releasing is best effort and never awaited: a body stream that errors
65
+ * after the 2xx headers (a connection reset) rejects `cancel()`, and that
66
+ * rejection must not turn a committed write into a thrown one, which a
67
+ * caller would retry into `CONFLICT`. The failure is reported on
68
+ * `console.warn` instead: the write stands, and the stream is already dead.
69
+ */
70
+ private write;
71
+ /** `row` with its `userId`: the stored one, or the token's user for a legacy row without one. */
72
+ private owned;
73
+ /** The token's user, read once; a failed read is not kept, so the next row asks again. */
74
+ private owner;
75
+ /** A point read: `null` (the port's `undefined`) or the row `read` proves. */
76
+ private pointRead;
77
+ /**
78
+ * The op's `{ result }` envelope, its `result` read by `read`. `undefined`
79
+ * from `read` (the result is not this op's answer) is `BAD_RESPONSE`.
83
80
  */
84
81
  private call;
82
+ /**
83
+ * One op on the wire: the 2xx response (its body unread), or the typed error. One shared retry budget (`retried`) covers BOTH retry
84
+ * triggers — a network failure and a 5xx response — so a retryable op makes
85
+ * at most 2 total requests, never 3.
86
+ */
87
+ private send;
85
88
  }
86
89
  //# sourceMappingURL=http.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAEnB,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,4BAA4B;IAC3C,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,gFAAgF;IAChF,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAaD;;;;;;GAMG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAKhC;AAgBD,qBAAa,qBAAsB,YAAW,qBAAqB;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,IAAI,EAAE,4BAA4B;IAO9C,oEAAoE;IAC9D,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC;IAI7B,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAI3D,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAItF,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAIhF,qBAAqB,CACzB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAUlC,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIrE,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAMxD;;;;OAIG;YACW,IAAI;CAqCnB"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAmDA,OAAO,KAAK,EACV,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAEnB,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,4BAA4B;IAC3C,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,gFAAgF;IAChF,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAKhC;AAqCD,qBAAa,qBAAsB,YAAW,qBAAqB;IACjE;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAuB;IAEpD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,mFAAmF;IACnF,OAAO,CAAC,SAAS,CAA8B;gBAEnC,IAAI,EAAE,4BAA4B;IAO9C,oEAAoE;IAC9D,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC;IAI7B,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAI3D,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAatF,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAKhF,qBAAqB,CACzB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAKlC,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIrE,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAMxD;;;;;;;;;;OAUG;YACW,KAAK;IAOnB,iGAAiG;YACnF,KAAK;IAInB,0FAA0F;IAC1F,OAAO,CAAC,KAAK;IAWb,8EAA8E;YAChE,SAAS;IAKvB;;;OAGG;YACW,IAAI;IAgBlB;;;;OAIG;YACW,IAAI;CAoCnB"}
package/dist/http.js CHANGED
@@ -1,8 +1,52 @@
1
+ /**
2
+ * Hosted HTTP binding — {@link ThreadPersistencePort} over guuey's thread
3
+ * API, `POST <base>/v1/thread-store/<op>` (guuey#208). The "eject the code,
4
+ * keep your memory" leg of the ejected ladder: point a `ThreadStore` at
5
+ * this binding with an end-user token and your self-hosted agent reads and
6
+ * writes the SAME conversation rows guuey's hosted runtime does.
7
+ *
8
+ * Wire protocol (the `@guuey/state` `HttpKv` shape):
9
+ * - Request: JSON body `{ context: { appId }, args: {...} }`, header
10
+ * `Authorization: Bearer <token>`.
11
+ * - Success: `200 { result }`.
12
+ * - Failure: `4xx/5xx { code, message }` → {@link HttpThreadStoreError}.
13
+ * - Every read's body is parsed to `unknown` and read through `http-read.ts`;
14
+ * a 2xx whose body is not an envelope, or whose `result` is not what the op
15
+ * answers, is an {@link HttpThreadStoreError} with code `BAD_RESPONSE`. A
16
+ * write's 2xx body is never read: the write has committed, and a body that
17
+ * does not parse must not turn it into a failure a caller would retry (into
18
+ * `CONFLICT`, since `createThread` and `putMessage` are conditional).
19
+ * - A message row an older writer stored without a `userId` gets the token's
20
+ * user (read once from {@link HttpThreadPersistence.scope}): the server
21
+ * answers only threads that user owns, and every row the port writes there
22
+ * carries that user.
23
+ *
24
+ * Auth + tenancy: the token is an END-USER token for the app — one the
25
+ * app's configured identity issuer minted (your own IdP, or guuey's per-app
26
+ * widget issuer via `@guuey/widget-auth`). The server verifies it against
27
+ * that issuer, derives the same `byo_…` userId the hosted runtime would, and
28
+ * scopes every op to `(appId, userId)`: rows you write must carry that
29
+ * userId, threads you touch must belong to it. `context` on the wire is
30
+ * advisory — the token is authoritative. Call {@link scope} to learn the
31
+ * derived userId (and the API's region) before `ensureThread`.
32
+ *
33
+ * Retry policy: reads (`getThread`/`listRecentMessages`/
34
+ * `findByClientMessageId`/`getSnapshot`/`scope`) are idempotent and retried
35
+ * once on a network failure or a 5xx. Writes (`createThread`/`incrementSeq`/
36
+ * `putMessage`/`putSnapshot`) are NEVER retried automatically —
37
+ * `incrementSeq` is not idempotent, and a blind retry after a server-side
38
+ * apply would double-allocate a seq.
39
+ */
40
+ import { DYNAMODB_ROW_BUDGET } from "./item-size.js";
41
+ import { ThreadClosedError } from "./closed.js";
42
+ import { isWireObject, readAllocatedSeq, readThreadMessageRow, readThreadMessageRows, readThreadRow, readThreadScope, readThreadSnapshotRow, } from "./http-read.js";
1
43
  /**
2
44
  * Thrown for every non-2xx response and every exhausted network failure.
3
45
  * `code` is the server's flat error code (`UNAUTHORIZED`, `FORBIDDEN`,
4
46
  * `INVALID_ARGUMENT`, `INVALID_CONTEXT`, `THREAD_NOT_FOUND`, `CONFLICT`,
5
- * `NOT_FOUND`, `TRANSPORT`); `status` is the HTTP status (`0` for a network
47
+ * `NOT_FOUND`, `TRANSPORT`, and the client's own `BAD_RESPONSE` for a 2xx body
48
+ * it cannot read; `incrementSeq`'s `THREAD_CLOSED` is thrown as
49
+ * `ThreadClosedError` instead); `status` is the HTTP status (`0` for a network
6
50
  * failure that never produced a response).
7
51
  */
8
52
  export class HttpThreadStoreError extends Error {
@@ -15,21 +59,38 @@ export class HttpThreadStoreError extends Error {
15
59
  this.name = "HttpThreadStoreError";
16
60
  }
17
61
  }
18
- async function toError(res) {
19
- let body;
62
+ async function parsedBody(res) {
20
63
  try {
21
- body = (await res.json());
64
+ const value = await res.json();
65
+ return { parsed: true, value };
22
66
  }
23
- catch {
24
- body = {};
67
+ catch (cause) {
68
+ return { parsed: false, cause };
25
69
  }
26
- return new HttpThreadStoreError(res.status, body.code ?? "TRANSPORT", body.message ?? `guuey thread API error (HTTP ${res.status})`);
70
+ }
71
+ async function toError(res) {
72
+ // `{ code, message }`, each read only when it is a string: a body that is
73
+ // not one (not JSON, a gateway's HTML page) keeps the transport defaults.
74
+ const body = await parsedBody(res);
75
+ const env = body.parsed && isWireObject(body.value) ? body.value : undefined;
76
+ return new HttpThreadStoreError(res.status, typeof env?.code === "string" ? env.code : "TRANSPORT", typeof env?.message === "string" ? env.message : `guuey thread API error (HTTP ${res.status})`, body.parsed ? undefined : { cause: body.cause });
77
+ }
78
+ /** The refusal for a 2xx whose body the op cannot read. */
79
+ function badResponse(res, op, what, cause) {
80
+ return new HttpThreadStoreError(res.status, "BAD_RESPONSE", `guuey thread API ${op}: ${what} (HTTP ${res.status})`, cause !== undefined ? { cause } : undefined);
27
81
  }
28
82
  export class HttpThreadPersistence {
83
+ /**
84
+ * guuey#1794: the server stores the rows this binding sends in DynamoDB, so
85
+ * the store sizes them by DynamoDB's own item rules before it sends any.
86
+ */
87
+ rowBudget = DYNAMODB_ROW_BUDGET;
29
88
  baseUrl;
30
89
  appId;
31
90
  token;
32
91
  fetchImpl;
92
+ /** The token's user, read from `scope` the first time a row lacks its `userId`. */
93
+ ownerRead;
33
94
  constructor(opts) {
34
95
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
35
96
  this.appId = opts.appId;
@@ -38,39 +99,102 @@ export class HttpThreadPersistence {
38
99
  }
39
100
  /** The `(appId, userId, region)` the server bound this token to. */
40
101
  async scope() {
41
- return this.call("scope", {}, true);
102
+ return this.call("scope", {}, true, readThreadScope);
42
103
  }
43
104
  async getThread(threadId) {
44
- return (await this.call("getThread", { threadId }, true)) ?? undefined;
105
+ return this.pointRead("getThread", { threadId }, readThreadRow);
45
106
  }
46
107
  async createThread(row) {
47
- await this.call("createThread", { row }, false);
108
+ await this.write("createThread", { row });
48
109
  }
49
110
  async incrementSeq(threadId, preview, atIso) {
50
- return this.call("incrementSeq", { threadId, preview, atIso }, false);
111
+ try {
112
+ return await this.call("incrementSeq", { threadId, preview, atIso }, false, readAllocatedSeq);
113
+ }
114
+ catch (err) {
115
+ // The server's word for a closed thread (guuey#2355) becomes the typed
116
+ // refusal every binding gives, so a ThreadStore reads it the same way.
117
+ if (err instanceof HttpThreadStoreError && err.code === "THREAD_CLOSED") {
118
+ throw new ThreadClosedError(threadId);
119
+ }
120
+ throw err;
121
+ }
51
122
  }
52
123
  async putMessage(row) {
53
- await this.call("putMessage", { row }, false);
124
+ await this.write("putMessage", { row });
54
125
  }
55
126
  async listRecentMessages(threadId, limit) {
56
- return this.call("listRecentMessages", { threadId, limit }, true);
127
+ const rows = await this.call("listRecentMessages", { threadId, limit }, true, readThreadMessageRows);
128
+ return Promise.all(rows.map((row) => this.owned(row)));
57
129
  }
58
130
  async findByClientMessageId(threadId, clientMessageId) {
59
- return ((await this.call("findByClientMessageId", { threadId, clientMessageId }, true)) ?? undefined);
131
+ const row = await this.pointRead("findByClientMessageId", { threadId, clientMessageId }, readThreadMessageRow);
132
+ return row === undefined ? undefined : this.owned(row);
60
133
  }
61
134
  async getSnapshot(threadId) {
62
- return (await this.call("getSnapshot", { threadId }, true)) ?? undefined;
135
+ return this.pointRead("getSnapshot", { threadId }, readThreadSnapshotRow);
63
136
  }
64
137
  async putSnapshot(row) {
65
- await this.call("putSnapshot", { row }, false);
138
+ await this.write("putSnapshot", { row });
66
139
  }
67
140
  // ── internals ──────────────────────────────────────────────────────
68
141
  /**
69
- * One shared retry budget (`retried`) covers BOTH retry triggers — a
70
- * network failure and a 5xx response — so a retryable op makes at most
71
- * 2 total requests, never 3.
142
+ * A write: never retried, and its body (the server's `{ result: null }`) is
143
+ * never read: a 2xx means it committed, whatever the body holds. The body is
144
+ * cancelled, not left open, so the connection is released.
145
+ *
146
+ * Releasing is best effort and never awaited: a body stream that errors
147
+ * after the 2xx headers (a connection reset) rejects `cancel()`, and that
148
+ * rejection must not turn a committed write into a thrown one, which a
149
+ * caller would retry into `CONFLICT`. The failure is reported on
150
+ * `console.warn` instead: the write stands, and the stream is already dead.
151
+ */
152
+ async write(op, args) {
153
+ const res = await this.send(op, args, false);
154
+ void res.body?.cancel().catch((err) => {
155
+ console.warn(`@guuey/threads: ${op} committed, but releasing its response body failed`, err);
156
+ });
157
+ }
158
+ /** `row` with its `userId`: the stored one, or the token's user for a legacy row without one. */
159
+ async owned(row) {
160
+ return { ...row, userId: row.userId ?? (await this.owner()) };
161
+ }
162
+ /** The token's user, read once; a failed read is not kept, so the next row asks again. */
163
+ owner() {
164
+ this.ownerRead ??= this.scope().then((scope) => scope.userId, (err) => {
165
+ this.ownerRead = undefined;
166
+ throw err;
167
+ });
168
+ return this.ownerRead;
169
+ }
170
+ /** A point read: `null` (the port's `undefined`) or the row `read` proves. */
171
+ async pointRead(op, args, read) {
172
+ const row = await this.call(op, args, true, (value) => (value === null ? null : read(value)));
173
+ return row ?? undefined;
174
+ }
175
+ /**
176
+ * The op's `{ result }` envelope, its `result` read by `read`. `undefined`
177
+ * from `read` (the result is not this op's answer) is `BAD_RESPONSE`.
178
+ */
179
+ async call(op, args, retryable, read) {
180
+ const res = await this.send(op, args, retryable);
181
+ const parsed = await parsedBody(res);
182
+ if (!parsed.parsed)
183
+ throw badResponse(res, op, "the body is not JSON", parsed.cause);
184
+ const body = parsed.value;
185
+ if (!isWireObject(body) || !("result" in body))
186
+ throw badResponse(res, op, "the body is not a { result } envelope");
187
+ const value = read(body.result);
188
+ if (value === undefined)
189
+ throw badResponse(res, op, "the result does not read as this op's answer");
190
+ return value;
191
+ }
192
+ /**
193
+ * One op on the wire: the 2xx response (its body unread), or the typed error. One shared retry budget (`retried`) covers BOTH retry
194
+ * triggers — a network failure and a 5xx response — so a retryable op makes
195
+ * at most 2 total requests, never 3.
72
196
  */
73
- async call(op, args, retryable) {
197
+ async send(op, args, retryable) {
74
198
  const doOnce = async () => this.fetchImpl(`${this.baseUrl}/v1/thread-store/${op}`, {
75
199
  method: "POST",
76
200
  headers: {
@@ -100,7 +224,6 @@ export class HttpThreadPersistence {
100
224
  }
101
225
  if (!res.ok)
102
226
  throw await toError(res);
103
- const body = (await res.json());
104
- return body.result;
227
+ return res;
105
228
  }
106
229
  }
@@ -1,10 +1,4 @@
1
- /**
2
- * InMemoryThreadPersistence — the dev/test binding of
3
- * {@link ThreadPersistencePort}. Same contract as any real binding (run
4
- * the suite in `@guuey/threads/testing` to prove yours), including the
5
- * two conditional-write guards and the null-preview no-touch semantics.
6
- */
7
- import type { ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow } from "./rows.js";
1
+ import type { ThreadClosure, ThreadMessageRow, ThreadPersistencePort, ThreadRow, ThreadSnapshotRow } from "./rows.js";
8
2
  export declare class InMemoryThreadPersistence implements ThreadPersistencePort {
9
3
  readonly threads: Map<string, ThreadRow>;
10
4
  readonly messages: ThreadMessageRow[];
@@ -12,6 +6,7 @@ export declare class InMemoryThreadPersistence implements ThreadPersistencePort
12
6
  getThread(threadId: string): Promise<ThreadRow | undefined>;
13
7
  createThread(row: ThreadRow): Promise<void>;
14
8
  incrementSeq(threadId: string, preview: string | null, atIso: string): Promise<number>;
9
+ closeThread(threadId: string, closure: ThreadClosure): Promise<"closed" | "already_closed">;
15
10
  putMessage(row: ThreadMessageRow): Promise<void>;
16
11
  listRecentMessages(threadId: string, limit: number): Promise<ThreadMessageRow[]>;
17
12
  findByClientMessageId(threadId: string, clientMessageId: string): Promise<ThreadMessageRow | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../src/in-memory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAEnB,qBAAa,yBAA0B,YAAW,qBAAqB;IACrE,QAAQ,CAAC,OAAO,yBAAgC;IAChD,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAM;IAC3C,QAAQ,CAAC,SAAS,iCAAwC;IAEpD,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAI3D,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAO3C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAYtF,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAQhF,qBAAqB,CACzB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAMlC,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIrE,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGzD"}
1
+ {"version":3,"file":"in-memory.d.ts","sourceRoot":"","sources":["../src/in-memory.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,aAAa,EACb,gBAAgB,EAChB,qBAAqB,EACrB,SAAS,EACT,iBAAiB,EAClB,MAAM,WAAW,CAAC;AAEnB,qBAAa,yBAA0B,YAAW,qBAAqB;IACrE,QAAQ,CAAC,OAAO,yBAAgC;IAChD,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAM;IAC3C,QAAQ,CAAC,SAAS,iCAAwC;IAEpD,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAI3D,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAO3C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBtF,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,QAAQ,GAAG,gBAAgB,CAAC;IAU3F,UAAU,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOhD,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAQhF,qBAAqB,CACzB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAMlC,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIrE,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;CAGzD"}