summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 18:37:09 +0900
committerAdam Malczewski <[email protected]>2026-06-12 18:37:09 +0900
commitdbf77ba78ff840e0ed5f6294030523fe3ab121fa (patch)
treee768aef3edd0c126212058c3d1433355594c49be /packages/conversation-store/src
parent6689eb51b467d8e370f31495840d88661f978168 (diff)
downloaddispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.tar.gz
dispatch-dbf77ba78ff840e0ed5f6294030523fe3ab121fa.zip
feat(history): CR-5 windowed reads — ?limit= / ?beforeSeq= on GET /conversations/:id
Selection sinceSeq < seq < beforeSeq; newest-limit window, ascending; positive- integer validation (400, store never sees an invalid window); 1-based gap-free seq codified as the contractual has-older mechanism (no earliestSeq field). transport-contract 0.9.0->0.10.0, wire 0.6.0->0.6.1 (doc-only). conversation-store +8 tests, transport-http +20; 935 vitest + 112 bun green. Live-verified: 6/6 probe checks OK. FE courier: frontend-history-windowing-handoff.md
Diffstat (limited to 'packages/conversation-store/src')
-rw-r--r--packages/conversation-store/src/store.test.ts88
-rw-r--r--packages/conversation-store/src/store.ts45
2 files changed, 132 insertions, 1 deletions
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts
index 49b0e58..5b07eca 100644
--- a/packages/conversation-store/src/store.test.ts
+++ b/packages/conversation-store/src/store.test.ts
@@ -499,6 +499,94 @@ describe("ConversationStore", () => {
});
});
+describe("ConversationStore loadSince windowing", () => {
+ let storage: StorageNamespace;
+
+ beforeEach(() => {
+ storage = createMemoryStorage();
+ });
+
+ // Append `count` single-chunk user messages so seq runs 1..count, gap-free.
+ async function seed(store: ReturnType<typeof createConversationStore>, count: number) {
+ const messages: ChatMessage[] = [];
+ for (let i = 1; i <= count; i++) {
+ messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] });
+ }
+ await store.append("conv1", messages);
+ }
+
+ it("limit returns the newest N of the selection, ascending by seq", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 5);
+ const chunks = await store.loadSince("conv1", 0, { limit: 2 });
+ expect(chunks.map((c) => c.seq)).toEqual([4, 5]);
+ });
+
+ it("limit >= selection size returns the whole selection (exact, not truncated)", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 3);
+ const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 });
+ expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]);
+ const overAll = await store.loadSince("conv1", 0, { limit: 99 });
+ expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]);
+ });
+
+ it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 5);
+ const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 });
+ expect(chunks.map((c) => c.seq)).toEqual([1, 2]);
+ });
+
+ it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 6);
+ const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 });
+ expect(chunks.map((c) => c.seq)).toEqual([3, 4]);
+ });
+
+ it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 8);
+ const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 });
+ expect(chunks.map((c) => c.seq)).toEqual([4, 5]);
+ });
+
+ it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 4);
+ expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]);
+ expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]);
+ });
+
+ it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 4);
+ const all = [1, 2, 3, 4];
+ expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all);
+ expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all);
+ expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all);
+ expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all);
+ expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all);
+ expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all);
+ });
+
+ it("window omitted is identical to today's behavior (regression guard)", async () => {
+ const store = createConversationStore(storage);
+ await seed(store, 5);
+ const base = await store.loadSince("conv1", 1);
+ const withEmptyWindow = await store.loadSince("conv1", 1, {});
+ // A caller whose window fields happen to be undefined (e.g. unset query
+ // params) — modelled as an optional-field record, not explicit `undefined`
+ // literals (which exactOptionalPropertyTypes rejects on the contract).
+ const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {};
+ const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow);
+ expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]);
+ expect(withEmptyWindow).toEqual(base);
+ expect(withUndefinedFields).toEqual(base);
+ });
+});
+
describe("ConversationStore metrics", () => {
let storage: StorageNamespace;
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index 0948f64..0a42917 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -23,9 +23,33 @@ import { reconcileWithReport } from "./reconcile.js";
export interface ConversationStore {
readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>;
readonly load: (conversationId: string) => Promise<ChatMessage[]>;
+ /**
+ * Read the conversation's persisted chunks as a SELECTION + optional WINDOW,
+ * ascending by seq. The raw append-order log; NOT reconciled (a dangling
+ * tool-call is returned as-is — repair is a turn-path concern).
+ *
+ * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`;
+ * omitted/`0`/non-positive/non-integer = from the start). When
+ * `window.beforeSeq` is given it is an exclusive upper bound
+ * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`.
+ * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the
+ * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit`
+ * chunks is returned whole (exact, not truncated).
+ * - **Omitted = unchanged** — `window` absent (or both its fields undefined)
+ * is byte-identical to the pre-windowing behavior, so existing callers that
+ * pass no third argument are unaffected.
+ * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or
+ * `beforeSeq`) is treated as ABSENT (full selection); this method never
+ * throws on bad window input. The transport validates and 400s upstream.
+ *
+ * Seq numbering is 1-based and gap-free, so a client derives "older chunks
+ * exist" purely from the oldest returned `seq > 1`; there is deliberately no
+ * `earliestSeq`/high-water-mark API.
+ */
readonly loadSince: (
conversationId: string,
sinceSeq?: number,
+ window?: { readonly beforeSeq?: number; readonly limit?: number },
) => Promise<readonly StoredChunk[]>;
readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>;
readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>;
@@ -37,6 +61,16 @@ export interface ConversationStore {
export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
+/**
+ * Coerce a window bound to a positive integer, or `undefined` (= absent) for any
+ * non-positive / non-integer / undefined input. Keeps `loadSince` total.
+ */
+function positiveInt(value: number | undefined): number | undefined {
+ if (value === undefined) return undefined;
+ if (!Number.isInteger(value) || value <= 0) return undefined;
+ return value;
+}
+
interface PersistedChunkEntry {
readonly chunk: Chunk;
readonly role: Role;
@@ -118,23 +152,32 @@ export function createConversationStore(
return repaired;
},
- async loadSince(conversationId, sinceSeq) {
+ async loadSince(conversationId, sinceSeq, window) {
const prefix = chunkPrefix(conversationId);
const keys = await storage.keys(prefix);
const sorted = [...keys].sort();
const result: StoredChunk[] = [];
const minSeq = sinceSeq ?? 0;
+ // Forgiving: a non-positive / non-integer bound is treated as ABSENT.
+ const beforeSeq = positiveInt(window?.beforeSeq);
+ const limit = positiveInt(window?.limit);
for (const key of sorted) {
const seq = parseSeq(key.split(":").pop() ?? null);
if (seq <= minSeq) continue;
+ if (beforeSeq !== undefined && seq >= beforeSeq) continue;
const value = await storage.get(key);
if (value === null) continue;
const entry = JSON.parse(value) as PersistedChunkEntry;
result.push({ seq, role: entry.role, chunk: entry.chunk });
}
+ // Window: keep only the NEWEST `limit` chunks, still ascending by seq.
+ if (limit !== undefined && result.length > limit) {
+ return result.slice(result.length - limit);
+ }
+
return result;
},