Skip to content

Commit 4316cd4

Browse files
committed
Implement LayeredStore class combining local and remote indexes
Agent-Id: agent-49ab3e98-e947-4ed1-8796-9454c317bd15
1 parent 94d91f1 commit 4316cd4

File tree

3 files changed

+330
-0
lines changed

3 files changed

+330
-0
lines changed

src/stores/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type { MemoryStoreConfig } from "./memory.js";
1010
export { S3Store } from "./s3.js";
1111
export type { S3StoreConfig } from "./s3.js";
1212
export { CompositeStoreReader } from "./composite.js";
13+
export { LayeredStore } from "./layered-store.js";
1314
export { parseIndexSpec, parseIndexSpecs } from "./index-spec.js";
1415
export type { IndexSpec } from "./index-spec.js";
1516

src/stores/layered-store.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Tests for LayeredStore
3+
*/
4+
5+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
6+
import { promises as fs } from "node:fs";
7+
import { join } from "node:path";
8+
import { LayeredStore } from "./layered-store.js";
9+
import { FilesystemStore } from "./filesystem.js";
10+
import { CompositeStoreReader } from "./composite.js";
11+
import type { IndexState, IndexStateSearchOnly } from "../core/types.js";
12+
13+
const TEST_DIR = "/tmp/context-connectors-test-layered-store";
14+
15+
// Create a minimal mock IndexState for testing
16+
function createMockState(id: string): {
17+
full: IndexState;
18+
search: IndexStateSearchOnly;
19+
} {
20+
const source = {
21+
type: "github" as const,
22+
config: { owner: "test-owner", repo: "test-repo" },
23+
syncedAt: new Date().toISOString(),
24+
};
25+
return {
26+
full: {
27+
version: 1,
28+
contextState: {
29+
mode: "full" as const,
30+
checkpointId: `checkpoint-${id}`,
31+
addedBlobs: ["blob-1", "blob-2"],
32+
deletedBlobs: [],
33+
blobs: [
34+
["blob-1", "src/file1.ts"],
35+
["blob-2", "src/file2.ts"],
36+
],
37+
},
38+
source,
39+
},
40+
search: {
41+
version: 1,
42+
contextState: {
43+
mode: "search-only" as const,
44+
checkpointId: `checkpoint-${id}`,
45+
addedBlobs: ["blob-1", "blob-2"],
46+
deletedBlobs: [],
47+
},
48+
source,
49+
},
50+
};
51+
}
52+
53+
describe("LayeredStore", () => {
54+
let localStore: FilesystemStore;
55+
let remoteStore: CompositeStoreReader;
56+
let layered: LayeredStore;
57+
58+
beforeEach(async () => {
59+
// Clean up test directory before each test
60+
await fs.rm(TEST_DIR, { recursive: true, force: true });
61+
62+
// Create local store
63+
localStore = new FilesystemStore({ basePath: TEST_DIR });
64+
65+
// Create empty remote store (no specs)
66+
remoteStore = await CompositeStoreReader.fromSpecs([]);
67+
68+
// Create layered store
69+
layered = new LayeredStore(localStore, remoteStore);
70+
});
71+
72+
afterEach(async () => {
73+
// Clean up test directory after each test
74+
await fs.rm(TEST_DIR, { recursive: true, force: true });
75+
});
76+
77+
describe("save", () => {
78+
it("saves to local store only", async () => {
79+
const { full, search } = createMockState("local");
80+
81+
await layered.save("test-index", full, search);
82+
83+
// Verify it was saved to local
84+
const loaded = await localStore.loadState("test-index");
85+
expect(loaded).not.toBeNull();
86+
expect(loaded!.contextState.checkpointId).toBe("checkpoint-local");
87+
});
88+
});
89+
90+
describe("loadState", () => {
91+
it("loads from local store when available", async () => {
92+
const { full, search } = createMockState("local");
93+
await layered.save("test-index", full, search);
94+
95+
const loaded = await layered.loadState("test-index");
96+
97+
expect(loaded).not.toBeNull();
98+
expect(loaded!.contextState.checkpointId).toBe("checkpoint-local");
99+
});
100+
101+
it("returns null when index not found in either store", async () => {
102+
const loaded = await layered.loadState("nonexistent");
103+
104+
expect(loaded).toBeNull();
105+
});
106+
});
107+
108+
describe("loadSearch", () => {
109+
it("loads search state from local store when available", async () => {
110+
const { full, search } = createMockState("local");
111+
await layered.save("test-index", full, search);
112+
113+
const loaded = await layered.loadSearch("test-index");
114+
115+
expect(loaded).not.toBeNull();
116+
expect(loaded!.contextState.checkpointId).toBe("checkpoint-local");
117+
});
118+
119+
it("returns null when search index not found", async () => {
120+
const loaded = await layered.loadSearch("nonexistent");
121+
122+
expect(loaded).toBeNull();
123+
});
124+
});
125+
126+
describe("list", () => {
127+
it("lists indexes from local store", async () => {
128+
const { full, search } = createMockState("1");
129+
await layered.save("index-1", full, search);
130+
await layered.save("index-2", full, search);
131+
132+
const list = await layered.list();
133+
134+
expect(list).toContain("index-1");
135+
expect(list).toContain("index-2");
136+
});
137+
138+
it("returns sorted list", async () => {
139+
const { full, search } = createMockState("1");
140+
await layered.save("zebra", full, search);
141+
await layered.save("apple", full, search);
142+
await layered.save("banana", full, search);
143+
144+
const list = await layered.list();
145+
146+
expect(list).toEqual(["apple", "banana", "zebra"]);
147+
});
148+
});
149+
150+
describe("delete", () => {
151+
it("deletes from local store", async () => {
152+
const { full, search } = createMockState("local");
153+
await layered.save("test-index", full, search);
154+
155+
await layered.delete("test-index");
156+
157+
const loaded = await layered.loadState("test-index");
158+
expect(loaded).toBeNull();
159+
});
160+
161+
it("throws error when trying to delete remote-only index", async () => {
162+
// Create a remote store with an index
163+
const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([
164+
{ type: "name", displayName: "remote-index", value: "remote-index" },
165+
]);
166+
167+
const layeredWithRemote = new LayeredStore(
168+
localStore,
169+
remoteStoreWithIndex
170+
);
171+
172+
// Try to delete the remote-only index
173+
await expect(
174+
layeredWithRemote.delete("remote-index")
175+
).rejects.toThrow(
176+
"Cannot delete remote index 'remote-index'. Remote indexes are read-only."
177+
);
178+
});
179+
180+
it("allows deletion of local index even if it exists in remote", async () => {
181+
const { full, search } = createMockState("local");
182+
183+
// Save to local
184+
await layered.save("test-index", full, search);
185+
186+
// Create a remote store with the same index
187+
const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([
188+
{ type: "name", displayName: "test-index", value: "test-index" },
189+
]);
190+
191+
const layeredWithRemote = new LayeredStore(
192+
localStore,
193+
remoteStoreWithIndex
194+
);
195+
196+
// Delete should succeed (deletes from local)
197+
await expect(layeredWithRemote.delete("test-index")).resolves.toBeUndefined();
198+
199+
// Verify it's deleted from local but still in remote
200+
const localLoaded = await localStore.loadState("test-index");
201+
expect(localLoaded).toBeNull();
202+
});
203+
});
204+
});
205+

src/stores/layered-store.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Layered Store - Combines writable local storage with read-only remote indexes.
3+
*
4+
* Provides a unified interface that:
5+
* - Reads from local storage first, then falls back to remote
6+
* - Writes only to local storage
7+
* - Lists indexes from both sources (deduplicated)
8+
* - Prevents deletion of remote-only indexes
9+
*
10+
* @module stores/layered-store
11+
*
12+
* @example
13+
* ```typescript
14+
* import { LayeredStore, FilesystemStore, CompositeStoreReader } from "@augmentcode/context-connectors/stores";
15+
*
16+
* const localStore = new FilesystemStore();
17+
* const remoteStore = await CompositeStoreReader.fromSpecs(specs);
18+
* const layered = new LayeredStore(localStore, remoteStore);
19+
*
20+
* // Read from local first, then remote
21+
* const state = await layered.loadState("my-index");
22+
*
23+
* // Write only to local
24+
* await layered.save("my-index", fullState, searchState);
25+
*
26+
* // List all available indexes
27+
* const keys = await layered.list();
28+
* ```
29+
*/
30+
31+
import type { IndexStore, IndexStoreReader } from "./types.js";
32+
import type { IndexState, IndexStateSearchOnly } from "../core/types.js";
33+
import type { FilesystemStore } from "./filesystem.js";
34+
import type { CompositeStoreReader } from "./composite.js";
35+
36+
/**
37+
* Layered store that combines a writable local store with a read-only remote store.
38+
*
39+
* Implements the IndexStore interface by delegating:
40+
* - Read operations to local first, then remote
41+
* - Write operations to local only
42+
* - List operations to both (deduplicated)
43+
*/
44+
export class LayeredStore implements IndexStore {
45+
private readonly localStore: FilesystemStore;
46+
private readonly remoteStore: CompositeStoreReader;
47+
48+
/**
49+
* Create a new LayeredStore.
50+
*
51+
* @param localStore - Writable local filesystem store
52+
* @param remoteStore - Read-only remote composite store
53+
*/
54+
constructor(localStore: FilesystemStore, remoteStore: CompositeStoreReader) {
55+
this.localStore = localStore;
56+
this.remoteStore = remoteStore;
57+
}
58+
59+
async loadState(key: string): Promise<IndexState | null> {
60+
// Try local first
61+
const localState = await this.localStore.loadState(key);
62+
if (localState !== null) {
63+
return localState;
64+
}
65+
66+
// Fall back to remote
67+
return this.remoteStore.loadState(key);
68+
}
69+
70+
async loadSearch(key: string): Promise<IndexStateSearchOnly | null> {
71+
// Try local first
72+
const localSearch = await this.localStore.loadSearch(key);
73+
if (localSearch !== null) {
74+
return localSearch;
75+
}
76+
77+
// Fall back to remote
78+
return this.remoteStore.loadSearch(key);
79+
}
80+
81+
async save(
82+
key: string,
83+
fullState: IndexState,
84+
searchState: IndexStateSearchOnly
85+
): Promise<void> {
86+
// Always save to local store only
87+
await this.localStore.save(key, fullState, searchState);
88+
}
89+
90+
async delete(key: string): Promise<void> {
91+
// Get lists from both stores
92+
const localList = await this.localStore.list();
93+
const remoteList = await this.remoteStore.list();
94+
95+
// Check if the key exists in local
96+
const existsInLocal = localList.includes(key);
97+
98+
// Check if the key exists in remote
99+
const existsInRemote = remoteList.includes(key);
100+
101+
// If it only exists in remote, throw an error
102+
if (!existsInLocal && existsInRemote) {
103+
throw new Error(
104+
`Cannot delete remote index '${key}'. Remote indexes are read-only.`
105+
);
106+
}
107+
108+
// Delete from local (no-op if doesn't exist)
109+
await this.localStore.delete(key);
110+
}
111+
112+
async list(): Promise<string[]> {
113+
// Get lists from both stores
114+
const [localList, remoteList] = await Promise.all([
115+
this.localStore.list(),
116+
this.remoteStore.list(),
117+
]);
118+
119+
// Merge and deduplicate
120+
const merged = new Set([...localList, ...remoteList]);
121+
return Array.from(merged).sort();
122+
}
123+
}
124+

0 commit comments

Comments
 (0)