@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.
- package/README.md +81 -0
- package/dist/closed.d.ts +22 -0
- package/dist/closed.d.ts.map +1 -0
- package/dist/closed.js +28 -0
- package/dist/fold-rows.d.ts +278 -5
- package/dist/fold-rows.d.ts.map +1 -1
- package/dist/fold-rows.js +475 -21
- package/dist/http-read.d.ts +47 -0
- package/dist/http-read.d.ts.map +1 -0
- package/dist/http-read.js +274 -0
- package/dist/http.d.ts +37 -34
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +145 -22
- package/dist/in-memory.d.ts +2 -7
- package/dist/in-memory.d.ts.map +1 -1
- package/dist/in-memory.js +27 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/item-size.d.ts +56 -0
- package/dist/item-size.d.ts.map +1 -0
- package/dist/item-size.js +125 -0
- package/dist/rows.d.ts +122 -1
- package/dist/rows.d.ts.map +1 -1
- package/dist/rows.js +13 -1
- package/dist/seal.d.ts +63 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +120 -0
- package/dist/snapshot-plan.d.ts +61 -0
- package/dist/snapshot-plan.d.ts.map +1 -0
- package/dist/snapshot-plan.js +71 -0
- package/dist/store.d.ts +130 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +274 -66
- package/dist/stored-row.d.ts +17 -3
- package/dist/stored-row.d.ts.map +1 -1
- package/dist/stored-row.js +2 -21
- package/dist/testing/contract-suite.d.ts +37 -1
- package/dist/testing/contract-suite.d.ts.map +1 -1
- package/dist/testing/contract-suite.js +122 -1
- package/package.json +3 -3
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* contract-suite pattern (guuey#107).
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { ThreadClosedError, threadClosure } from "../closed.js";
|
|
10
|
+
import { DYNAMODB_ITEM_LIMIT_BYTES, DYNAMODB_ROW_BUDGET, dynamoItemBytes, dynamoNumberFits } from "../item-size.js";
|
|
9
11
|
function threadRow(id) {
|
|
10
12
|
const now = "2026-08-07T00:00:00.000Z";
|
|
11
13
|
return {
|
|
@@ -37,8 +39,47 @@ async function withHarness(make, fn) {
|
|
|
37
39
|
* Run the port contract against a binding. `make` must yield a FRESH,
|
|
38
40
|
* empty store per call.
|
|
39
41
|
*/
|
|
40
|
-
export function runThreadPersistenceContractSuite(name, make) {
|
|
42
|
+
export function runThreadPersistenceContractSuite(name, make, options = {}) {
|
|
41
43
|
describe(`ThreadPersistencePort contract — ${name}`, () => {
|
|
44
|
+
if (options.canClose === true) {
|
|
45
|
+
const closure = { code: "app:CHAT_FULL", message: "This chat is full.", at: "2026-08-07T00:00:05.000Z" };
|
|
46
|
+
it("closeThread records the closure once; a second close is already_closed; a missing thread rejects", async () => {
|
|
47
|
+
await withHarness(make, async (port) => {
|
|
48
|
+
expect(port.closeThread).toBeTypeOf("function");
|
|
49
|
+
if (port.closeThread === undefined)
|
|
50
|
+
return;
|
|
51
|
+
await port.createThread(threadRow("t1"));
|
|
52
|
+
expect(await port.closeThread("t1", closure)).toBe("closed");
|
|
53
|
+
expect(await port.closeThread("t1", { ...closure, code: "app:OTHER" })).toBe("already_closed");
|
|
54
|
+
const t = await port.getThread("t1");
|
|
55
|
+
expect(t && threadClosure(t)).toEqual(closure);
|
|
56
|
+
// Closing is not archiving: the status the thread had is kept.
|
|
57
|
+
expect(t?.status).toBe("active");
|
|
58
|
+
await expect(port.closeThread("t-missing", closure)).rejects.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it("incrementSeq on a closed thread rejects with ThreadClosedError and allocates no seq", async () => {
|
|
62
|
+
await withHarness(make, async (port) => {
|
|
63
|
+
if (port.closeThread === undefined)
|
|
64
|
+
throw new Error("canClose binding has no closeThread");
|
|
65
|
+
await port.createThread(threadRow("t1"));
|
|
66
|
+
expect(await port.incrementSeq("t1", "before", "2026-08-07T00:00:01.000Z")).toBe(1);
|
|
67
|
+
await port.closeThread("t1", closure);
|
|
68
|
+
await expect(port.incrementSeq("t1", "after", "2026-08-07T00:00:06.000Z")).rejects.toBeInstanceOf(ThreadClosedError);
|
|
69
|
+
await expect(port.incrementSeq("t1", null, "2026-08-07T00:00:06.000Z")).rejects.toBeInstanceOf(ThreadClosedError);
|
|
70
|
+
const t = await port.getThread("t1");
|
|
71
|
+
expect(t?.lastSeq).toBe(1);
|
|
72
|
+
expect(t?.lastMessagePreview).toBe("before");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
it("incrementSeq on a missing thread still rejects with something other than ThreadClosedError", async () => {
|
|
76
|
+
await withHarness(make, async (port) => {
|
|
77
|
+
const err = await port.incrementSeq("t-missing", "p", "2026-08-07T00:00:01.000Z").catch((e) => e);
|
|
78
|
+
expect(err).toBeInstanceOf(Error);
|
|
79
|
+
expect(err).not.toBeInstanceOf(ThreadClosedError);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
42
83
|
it("creates and point-reads a thread; unknown ids read undefined", async () => {
|
|
43
84
|
await withHarness(make, async (port) => {
|
|
44
85
|
await port.createThread(threadRow("t1"));
|
|
@@ -198,3 +239,83 @@ export function runThreadPersistenceContractSuite(name, make) {
|
|
|
198
239
|
});
|
|
199
240
|
});
|
|
200
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* The error {@link withItemSizeLimit} rejects an oversized row with: the shape
|
|
244
|
+
* DynamoDB answers a too-large `PutItem` with.
|
|
245
|
+
*/
|
|
246
|
+
export class ItemSizeExceededError extends Error {
|
|
247
|
+
bytes;
|
|
248
|
+
name = "ValidationException";
|
|
249
|
+
constructor(bytes) {
|
|
250
|
+
super("Item size has exceeded the maximum allowed size");
|
|
251
|
+
this.bytes = bytes;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The error {@link withItemSizeLimit} rejects a row carrying a number DynamoDB
|
|
256
|
+
* cannot hold with: like the DocumentClient's own, its message names the number.
|
|
257
|
+
*/
|
|
258
|
+
export class UnstorableNumberError extends Error {
|
|
259
|
+
value;
|
|
260
|
+
constructor(value) {
|
|
261
|
+
super(`Number ${String(value)} cannot be stored as written`);
|
|
262
|
+
this.value = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function firstUnfitNumber(value) {
|
|
266
|
+
if (typeof value === "number")
|
|
267
|
+
return dynamoNumberFits(value) ? undefined : value;
|
|
268
|
+
if (value === null || typeof value !== "object")
|
|
269
|
+
return undefined;
|
|
270
|
+
for (const child of Array.isArray(value) ? value : Object.values(value)) {
|
|
271
|
+
const found = firstUnfitNumber(child);
|
|
272
|
+
if (found !== undefined)
|
|
273
|
+
return found;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Wrap a port so it behaves like a DynamoDB-backed binding on what a row may
|
|
279
|
+
* hold (guuey#1794): it declares the DynamoDB row budget, and its
|
|
280
|
+
* `putMessage` (and, guuey#2120, its `putSnapshot`) rejects a row over DynamoDB's 400 KB item limit (measured by
|
|
281
|
+
* `dynamoItemBytes`) with {@link ItemSizeExceededError}, and a row carrying a
|
|
282
|
+
* number DynamoDB cannot hold with {@link UnstorableNumberError}, instead of
|
|
283
|
+
* storing it. Run a `ThreadStore` over it to test what happens at those
|
|
284
|
+
* limits without a real table.
|
|
285
|
+
*/
|
|
286
|
+
export function withItemSizeLimit(port) {
|
|
287
|
+
return {
|
|
288
|
+
rowBudget: DYNAMODB_ROW_BUDGET,
|
|
289
|
+
getThread: (threadId) => port.getThread(threadId),
|
|
290
|
+
createThread: (row) => port.createThread(row),
|
|
291
|
+
incrementSeq: (threadId, preview, atIso) => port.incrementSeq(threadId, preview, atIso),
|
|
292
|
+
...closeThreadOf(port),
|
|
293
|
+
async putMessage(row) {
|
|
294
|
+
const unfit = firstUnfitNumber(row);
|
|
295
|
+
if (unfit !== undefined)
|
|
296
|
+
throw new UnstorableNumberError(unfit);
|
|
297
|
+
const bytes = dynamoItemBytes(row);
|
|
298
|
+
if (bytes > DYNAMODB_ITEM_LIMIT_BYTES)
|
|
299
|
+
throw new ItemSizeExceededError(bytes);
|
|
300
|
+
await port.putMessage(row);
|
|
301
|
+
},
|
|
302
|
+
listRecentMessages: (threadId, limit) => port.listRecentMessages(threadId, limit),
|
|
303
|
+
findByClientMessageId: (threadId, clientMessageId) => port.findByClientMessageId(threadId, clientMessageId),
|
|
304
|
+
getSnapshot: (threadId) => port.getSnapshot(threadId),
|
|
305
|
+
// guuey#2120: the snapshot row is an item of the same store, refused the same way.
|
|
306
|
+
async putSnapshot(row) {
|
|
307
|
+
const unfit = firstUnfitNumber(row);
|
|
308
|
+
if (unfit !== undefined)
|
|
309
|
+
throw new UnstorableNumberError(unfit);
|
|
310
|
+
const bytes = dynamoItemBytes(row);
|
|
311
|
+
if (bytes > DYNAMODB_ITEM_LIMIT_BYTES)
|
|
312
|
+
throw new ItemSizeExceededError(bytes);
|
|
313
|
+
await port.putSnapshot(row);
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** `port`'s `closeThread`, forwarded, or nothing when it has none. */
|
|
318
|
+
function closeThreadOf(port) {
|
|
319
|
+
const close = port.closeThread;
|
|
320
|
+
return close === undefined ? {} : { closeThread: (threadId, closure) => close.call(port, threadId, closure) };
|
|
321
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guuey/threads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Universal session/thread persistence for AgJSON agents — the ThreadStore contract (append-fold, history, snapshots), the fold↔row mapping, and an in-memory binding. Guuey's hosted runtime is one binding; implement the port against your own store and run the exported contract suite for the same guarantees.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@silverprotocol/core": "0.12.
|
|
29
|
-
"@guuey/mcp-apps-host": "0.
|
|
28
|
+
"@silverprotocol/core": "0.12.7",
|
|
29
|
+
"@guuey/mcp-apps-host": "0.32.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^24.0.0",
|