@cosmocoder/mcp-web-docs 2.0.8 → 2.0.10
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/build/crawler/auth.js +117 -7
- package/build/crawler/auth.js.map +1 -1
- package/build/crawler/auth.test.js +333 -83
- package/build/crawler/auth.test.js.map +1 -1
- package/build/crawler/browser-config.d.ts +1 -1
- package/build/crawler/browser-config.js +7 -1
- package/build/crawler/browser-config.js.map +1 -1
- package/build/crawler/browser-config.test.js +21 -25
- package/build/crawler/browser-config.test.js.map +1 -1
- package/build/crawler/crawlee-crawler.d.ts +3 -0
- package/build/crawler/crawlee-crawler.js +155 -12
- package/build/crawler/crawlee-crawler.js.map +1 -1
- package/build/crawler/crawlee-crawler.test.js +190 -143
- package/build/crawler/crawlee-crawler.test.js.map +1 -1
- package/build/crawler/github.js +3 -2
- package/build/crawler/github.js.map +1 -1
- package/build/crawler/llms-txt.js +2 -1
- package/build/crawler/llms-txt.js.map +1 -1
- package/build/index.js +21 -24
- package/build/index.js.map +1 -1
- package/build/setupTests.js +44 -0
- package/build/setupTests.js.map +1 -1
- package/build/storage/storage.d.ts +30 -10
- package/build/storage/storage.js +567 -170
- package/build/storage/storage.js.map +1 -1
- package/build/storage/storage.test.js +721 -2
- package/build/storage/storage.test.js.map +1 -1
- package/build/types.d.ts +6 -1
- package/build/util/favicon.js +2 -1
- package/build/util/favicon.js.map +1 -1
- package/build/util/favicon.test.js +27 -37
- package/build/util/favicon.test.js.map +1 -1
- package/build/util/outbound-request.d.ts +33 -0
- package/build/util/outbound-request.integration.test.d.ts +1 -0
- package/build/util/outbound-request.integration.test.js +149 -0
- package/build/util/outbound-request.integration.test.js.map +1 -0
- package/build/util/outbound-request.js +367 -0
- package/build/util/outbound-request.js.map +1 -0
- package/build/util/outbound-request.test.d.ts +1 -0
- package/build/util/outbound-request.test.js +224 -0
- package/build/util/outbound-request.test.js.map +1 -0
- package/build/util/security.js +1 -1
- package/build/util/security.js.map +1 -1
- package/build/util/security.test.js +1 -0
- package/build/util/security.test.js.map +1 -1
- package/package.json +8 -6
|
@@ -3,18 +3,29 @@ import { createMockEmbeddings } from '../__mocks__/embeddings.js';
|
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
function deferred() {
|
|
7
|
+
let resolve;
|
|
8
|
+
const promise = new Promise((resolvePromise) => {
|
|
9
|
+
resolve = () => resolvePromise();
|
|
10
|
+
});
|
|
11
|
+
return { promise, resolve };
|
|
12
|
+
}
|
|
6
13
|
describe('DocumentStore', () => {
|
|
7
14
|
let store;
|
|
8
15
|
let tempDir;
|
|
9
16
|
let mockEmbeddings;
|
|
17
|
+
let openStores;
|
|
10
18
|
beforeEach(async () => {
|
|
11
19
|
// Create temporary directory for test databases
|
|
12
20
|
tempDir = await mkdtemp(join(tmpdir(), 'mcp-web-docs-test-'));
|
|
13
21
|
mockEmbeddings = createMockEmbeddings();
|
|
22
|
+
openStores = new Set();
|
|
14
23
|
store = new DocumentStore(join(tempDir, 'docs.db'), join(tempDir, 'vectors'), mockEmbeddings, 100);
|
|
24
|
+
openStores.add(store);
|
|
15
25
|
await store.initialize();
|
|
16
26
|
});
|
|
17
27
|
afterEach(async () => {
|
|
28
|
+
const closeResults = await Promise.allSettled([...openStores].map((openStore) => openStore.close()));
|
|
18
29
|
// Clean up temporary directory
|
|
19
30
|
try {
|
|
20
31
|
await rm(tempDir, { recursive: true, force: true });
|
|
@@ -22,6 +33,10 @@ describe('DocumentStore', () => {
|
|
|
22
33
|
catch {
|
|
23
34
|
// Ignore cleanup errors
|
|
24
35
|
}
|
|
36
|
+
const closeErrors = closeResults.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
|
|
37
|
+
if (closeErrors.length > 0) {
|
|
38
|
+
throw new AggregateError(closeErrors, 'Failed to close one or more test stores');
|
|
39
|
+
}
|
|
25
40
|
});
|
|
26
41
|
function createTestDocument(url, title, chunkCount = 1) {
|
|
27
42
|
const chunks = [];
|
|
@@ -51,6 +66,64 @@ describe('DocumentStore', () => {
|
|
|
51
66
|
chunks,
|
|
52
67
|
};
|
|
53
68
|
}
|
|
69
|
+
function replacementInternals(target = store) {
|
|
70
|
+
return target;
|
|
71
|
+
}
|
|
72
|
+
function createDocumentWithContent(url, title, content) {
|
|
73
|
+
const document = createTestDocument(url, title);
|
|
74
|
+
document.chunks[0].content = content;
|
|
75
|
+
return document;
|
|
76
|
+
}
|
|
77
|
+
async function openPeerStore() {
|
|
78
|
+
const peer = new DocumentStore(join(tempDir, 'docs.db'), join(tempDir, 'vectors'), mockEmbeddings, 100);
|
|
79
|
+
openStores.add(peer);
|
|
80
|
+
await peer.initialize();
|
|
81
|
+
return peer;
|
|
82
|
+
}
|
|
83
|
+
async function storedContents(target, url) {
|
|
84
|
+
const results = await target.searchByText('test content', { filterUrl: url, limit: 100 });
|
|
85
|
+
return results.map((result) => result.content).sort();
|
|
86
|
+
}
|
|
87
|
+
function blockNextLanceAdd(target = store) {
|
|
88
|
+
const table = replacementInternals(target).lanceTable;
|
|
89
|
+
const add = table.add.bind(table);
|
|
90
|
+
const staged = deferred();
|
|
91
|
+
const released = deferred();
|
|
92
|
+
vi.spyOn(table, 'add').mockImplementationOnce(async (data, options) => {
|
|
93
|
+
const result = await add(data, options);
|
|
94
|
+
staged.resolve();
|
|
95
|
+
await released.promise;
|
|
96
|
+
return result;
|
|
97
|
+
});
|
|
98
|
+
return { staged: staged.promise, release: released.resolve };
|
|
99
|
+
}
|
|
100
|
+
function blockPublication(target = store) {
|
|
101
|
+
const sqliteDb = replacementInternals(target).sqliteDb;
|
|
102
|
+
const run = sqliteDb.run.bind(sqliteDb);
|
|
103
|
+
const reached = deferred();
|
|
104
|
+
const released = deferred();
|
|
105
|
+
vi.spyOn(sqliteDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
106
|
+
if (String(sql).includes("SET state = 'published'")) {
|
|
107
|
+
reached.resolve();
|
|
108
|
+
await released.promise;
|
|
109
|
+
}
|
|
110
|
+
return run(sql, ...params);
|
|
111
|
+
});
|
|
112
|
+
return { reached: reached.promise, release: released.resolve };
|
|
113
|
+
}
|
|
114
|
+
function waitForLeaseContention(target) {
|
|
115
|
+
const leaseDb = replacementInternals(target).sqliteLeaseDb;
|
|
116
|
+
const run = leaseDb.run.bind(leaseDb);
|
|
117
|
+
const waiting = deferred();
|
|
118
|
+
vi.spyOn(leaseDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
119
|
+
const result = await run(sql, ...params);
|
|
120
|
+
if (String(sql).includes('INSERT OR IGNORE INTO document_replacements') && result.changes === 0) {
|
|
121
|
+
waiting.resolve();
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
});
|
|
125
|
+
return waiting.promise;
|
|
126
|
+
}
|
|
54
127
|
describe('initialize', () => {
|
|
55
128
|
it('should initialize storage successfully', async () => {
|
|
56
129
|
// Already initialized in beforeEach
|
|
@@ -65,6 +138,32 @@ describe('DocumentStore', () => {
|
|
|
65
138
|
const retrieved = await store.getDocument('https://example.com/test');
|
|
66
139
|
expect(retrieved).toBeDefined();
|
|
67
140
|
});
|
|
141
|
+
it('attempts every close when an earlier resource fails', async () => {
|
|
142
|
+
const internals = replacementInternals();
|
|
143
|
+
const closes = [
|
|
144
|
+
vi.spyOn(internals.lanceTable, 'close').mockImplementationOnce(() => {
|
|
145
|
+
throw new Error('injected table close failure');
|
|
146
|
+
}),
|
|
147
|
+
vi.spyOn(internals.lanceConn, 'close'),
|
|
148
|
+
vi.spyOn(internals.sqliteReadDb, 'close'),
|
|
149
|
+
vi.spyOn(internals.sqliteLeaseDb, 'close'),
|
|
150
|
+
vi.spyOn(internals.sqliteDb, 'close'),
|
|
151
|
+
];
|
|
152
|
+
await expect(store.close()).rejects.toThrow('Failed to close one or more storage resources');
|
|
153
|
+
expect(closes.map((close) => close.mock.calls.length)).toEqual([1, 1, 1, 1, 1]);
|
|
154
|
+
await expect(store.close()).resolves.toBeUndefined();
|
|
155
|
+
expect(closes.map((close) => close.mock.calls.length)).toEqual([2, 1, 1, 1, 1]);
|
|
156
|
+
});
|
|
157
|
+
it('preserves initialization failure when resource cleanup also fails', async () => {
|
|
158
|
+
const failingStore = new DocumentStore(join(tempDir, 'init-failure', 'docs.db'), join(tempDir, 'init-failure', 'vectors'), mockEmbeddings, 100);
|
|
159
|
+
const internals = replacementInternals(failingStore);
|
|
160
|
+
vi.spyOn(internals, 'createFTSIndex').mockRejectedValue(new Error('injected initialization failure'));
|
|
161
|
+
const close = vi.spyOn(failingStore, 'close').mockRejectedValueOnce(new Error('injected cleanup failure'));
|
|
162
|
+
await expect(failingStore.initialize()).rejects.toThrow('Failed to initialize LanceDB: injected initialization failure');
|
|
163
|
+
expect(close).toHaveBeenCalledOnce();
|
|
164
|
+
close.mockRestore();
|
|
165
|
+
await failingStore.close();
|
|
166
|
+
});
|
|
68
167
|
});
|
|
69
168
|
describe('addDocument', () => {
|
|
70
169
|
it('should add a document successfully', async () => {
|
|
@@ -104,6 +203,558 @@ describe('DocumentStore', () => {
|
|
|
104
203
|
expect(retrieved?.favicon).toBe('https://example.com/favicon.ico');
|
|
105
204
|
});
|
|
106
205
|
});
|
|
206
|
+
describe('generation replacement', () => {
|
|
207
|
+
it('keeps staged chunks hidden until publication and preserves document relationships', async () => {
|
|
208
|
+
const url = 'https://example.com/replace-safe';
|
|
209
|
+
const original = createTestDocument(url, 'Original', 3);
|
|
210
|
+
original.chunks.forEach((chunk, index) => (chunk.content = `old replacement content ${index}`));
|
|
211
|
+
await store.addDocument(original);
|
|
212
|
+
await store.setTags(url, ['stable', 'docs']);
|
|
213
|
+
await store.createCollection('Replacement Collection');
|
|
214
|
+
await store.addToCollection('Replacement Collection', [url]);
|
|
215
|
+
const replacement = createTestDocument(url, 'Replacement', 2);
|
|
216
|
+
replacement.chunks.forEach((chunk, index) => (chunk.content = `new replacement content ${index}`));
|
|
217
|
+
const publication = blockPublication();
|
|
218
|
+
const replacementPromise = store.addDocument(replacement, { tags: ['new-tag'] });
|
|
219
|
+
await publication.reached;
|
|
220
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original', tags: ['docs', 'stable'] });
|
|
221
|
+
expect((await store.listDocuments()).find((document) => document.url === url)).toMatchObject({
|
|
222
|
+
title: 'Original',
|
|
223
|
+
tags: ['docs', 'stable'],
|
|
224
|
+
});
|
|
225
|
+
expect((await store.getCollection('Replacement Collection'))?.documents[0]).toMatchObject({
|
|
226
|
+
title: 'Original',
|
|
227
|
+
tags: ['docs', 'stable'],
|
|
228
|
+
});
|
|
229
|
+
const journal = await replacementInternals().sqliteReadDb.get('SELECT cleanup_generations FROM document_replacements WHERE url = ?', [url]);
|
|
230
|
+
expect(JSON.parse(journal.cleanup_generations)).toHaveLength(1);
|
|
231
|
+
expect(await storedContents(store, url)).toEqual([
|
|
232
|
+
'old replacement content 0',
|
|
233
|
+
'old replacement content 1',
|
|
234
|
+
'old replacement content 2',
|
|
235
|
+
]);
|
|
236
|
+
publication.release();
|
|
237
|
+
await replacementPromise;
|
|
238
|
+
const metadata = await store.getDocument(url);
|
|
239
|
+
const collection = await store.getCollection('Replacement Collection');
|
|
240
|
+
expect(metadata).toMatchObject({ title: 'Replacement', tags: ['new-tag'] });
|
|
241
|
+
expect(collection?.documents[0]).toMatchObject({ title: 'Replacement', tags: ['new-tag'] });
|
|
242
|
+
expect(collection?.documents.map((document) => document.url)).toEqual([url]);
|
|
243
|
+
expect(await storedContents(store, url)).toEqual(['new replacement content 0', 'new replacement content 1']);
|
|
244
|
+
});
|
|
245
|
+
it.each(['add', 'delete'])('keeps the old document visible when %s preparation fails after lease acquisition', async (operation) => {
|
|
246
|
+
const url = 'https://example.com/prepare-failure';
|
|
247
|
+
await store.addDocument(createDocumentWithContent(url, 'Original', 'old prepare failure content'));
|
|
248
|
+
const leaseDb = replacementInternals().sqliteLeaseDb;
|
|
249
|
+
const writerRun = vi.spyOn(replacementInternals().sqliteDb, 'run');
|
|
250
|
+
const run = leaseDb.run.bind(leaseDb);
|
|
251
|
+
vi.spyOn(leaseDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
252
|
+
if (String(sql).includes('SET cleanup_generations = ?')) {
|
|
253
|
+
throw new Error('injected journal preparation failure');
|
|
254
|
+
}
|
|
255
|
+
return run(sql, ...params);
|
|
256
|
+
});
|
|
257
|
+
const failedOperation = operation === 'add'
|
|
258
|
+
? store.addDocument(createDocumentWithContent(url, 'Replacement', 'new prepare failure content'))
|
|
259
|
+
: store.deleteDocument(url);
|
|
260
|
+
await expect(failedOperation).rejects.toThrow('injected journal preparation failure');
|
|
261
|
+
expect(writerRun).not.toHaveBeenCalledWith('ROLLBACK');
|
|
262
|
+
expect(await leaseDb.get('SELECT url FROM document_replacements WHERE url = ?', [url])).toBeUndefined();
|
|
263
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
264
|
+
expect(await storedContents(store, url)).toEqual(['old prepare failure content']);
|
|
265
|
+
});
|
|
266
|
+
it('leaves the old document intact when staging fails', async () => {
|
|
267
|
+
const url = 'https://example.com/merge-failure';
|
|
268
|
+
const original = createTestDocument(url, 'Original', 2);
|
|
269
|
+
original.chunks.forEach((chunk, index) => (chunk.content = `old merge failure content ${index}`));
|
|
270
|
+
await store.addDocument(original);
|
|
271
|
+
vi.spyOn(replacementInternals().lanceTable, 'add').mockRejectedValueOnce(new Error('injected staging failure'));
|
|
272
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new merge failure content');
|
|
273
|
+
await expect(store.addDocument(replacement)).rejects.toThrow('injected staging failure');
|
|
274
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
275
|
+
expect(await storedContents(store, url)).toEqual(['old merge failure content 0', 'old merge failure content 1']);
|
|
276
|
+
});
|
|
277
|
+
it('keeps unpublished rows hidden, then recovers the prepared generation after lease expiry', async () => {
|
|
278
|
+
const url = 'https://example.com/hidden-publication-failure';
|
|
279
|
+
const otherUrl = 'https://example.com/unrelated';
|
|
280
|
+
const original = createTestDocument(url, 'Original', 2);
|
|
281
|
+
original.chunks.forEach((chunk, index) => (chunk.content = `old publication failure content ${index}`));
|
|
282
|
+
await store.addDocument(original);
|
|
283
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
284
|
+
const originalRun = sqliteDb.run.bind(sqliteDb);
|
|
285
|
+
vi.spyOn(sqliteDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
286
|
+
if (String(sql).includes('INSERT INTO documents')) {
|
|
287
|
+
throw new Error('injected publication failure');
|
|
288
|
+
}
|
|
289
|
+
return originalRun(sql, ...params);
|
|
290
|
+
});
|
|
291
|
+
vi.spyOn(replacementInternals(), 'finishDocumentReplacement').mockRejectedValueOnce(new Error('injected cleanup failure'));
|
|
292
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new publication failure content');
|
|
293
|
+
await expect(store.addDocument(replacement)).rejects.toThrow('injected publication failure');
|
|
294
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
295
|
+
expect(await storedContents(store, url)).toEqual(['old publication failure content 0', 'old publication failure content 1']);
|
|
296
|
+
expect(await sqliteDb.get('SELECT state FROM document_replacements WHERE url = ?', [url])).toMatchObject({ state: 'prepared' });
|
|
297
|
+
vi.restoreAllMocks();
|
|
298
|
+
await store.addDocument(createTestDocument(otherUrl, 'Unrelated'));
|
|
299
|
+
expect(await storedContents(store, otherUrl)).toEqual(['Test content for chunk 1 of Unrelated']);
|
|
300
|
+
await sqliteDb.run('UPDATE document_replacements SET lease_expires_at = 0 WHERE url = ?', [url]);
|
|
301
|
+
const recoveredStore = await openPeerStore();
|
|
302
|
+
expect(await storedContents(recoveredStore, url)).toEqual(['old publication failure content 0', 'old publication failure content 1']);
|
|
303
|
+
expect(await storedContents(recoveredStore, otherUrl)).toEqual(['Test content for chunk 1 of Unrelated']);
|
|
304
|
+
expect(await sqliteDb.get('SELECT url FROM document_replacements WHERE url = ?', [url])).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
it('keeps committed metadata and cached search results visible when publication is cancelled', async () => {
|
|
307
|
+
const url = 'https://example.com/cancelled-publication';
|
|
308
|
+
const original = createDocumentWithContent(url, 'Original', 'old cancellation content');
|
|
309
|
+
await store.addDocument(original);
|
|
310
|
+
const controller = new AbortController();
|
|
311
|
+
const publication = blockPublication();
|
|
312
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new cancellation content');
|
|
313
|
+
const replacementPromise = store.addDocument(replacement, { signal: controller.signal });
|
|
314
|
+
await publication.reached;
|
|
315
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
316
|
+
expect(await storedContents(store, url)).toEqual(['old cancellation content']);
|
|
317
|
+
controller.abort();
|
|
318
|
+
publication.release();
|
|
319
|
+
await expect(replacementPromise).rejects.toMatchObject({ name: 'AbortError' });
|
|
320
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
321
|
+
expect(await storedContents(store, url)).toEqual(['old cancellation content']);
|
|
322
|
+
});
|
|
323
|
+
it('keeps committed metadata and cached search results visible when publication commit fails', async () => {
|
|
324
|
+
const url = 'https://example.com/commit-failure';
|
|
325
|
+
const original = createDocumentWithContent(url, 'Original', 'old commit failure content');
|
|
326
|
+
await store.addDocument(original);
|
|
327
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
328
|
+
const originalRun = sqliteDb.run.bind(sqliteDb);
|
|
329
|
+
const commitReached = deferred();
|
|
330
|
+
const commitReleased = deferred();
|
|
331
|
+
vi.spyOn(sqliteDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
332
|
+
if (String(sql) === 'COMMIT') {
|
|
333
|
+
commitReached.resolve();
|
|
334
|
+
await commitReleased.promise;
|
|
335
|
+
throw new Error('injected commit failure');
|
|
336
|
+
}
|
|
337
|
+
return originalRun(sql, ...params);
|
|
338
|
+
});
|
|
339
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new commit failure content');
|
|
340
|
+
const replacementPromise = store.addDocument(replacement);
|
|
341
|
+
await commitReached.promise;
|
|
342
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
343
|
+
expect(await storedContents(store, url)).toEqual(['old commit failure content']);
|
|
344
|
+
commitReleased.resolve();
|
|
345
|
+
await expect(replacementPromise).rejects.toThrow('injected commit failure');
|
|
346
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
347
|
+
expect(await storedContents(store, url)).toEqual(['old commit failure content']);
|
|
348
|
+
});
|
|
349
|
+
it('preserves a published generation when COMMIT succeeds but reports an error', async () => {
|
|
350
|
+
const url = 'https://example.com/ambiguous-commit';
|
|
351
|
+
await store.addDocument(createDocumentWithContent(url, 'Original', 'old ambiguous commit content'));
|
|
352
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
353
|
+
const run = sqliteDb.run.bind(sqliteDb);
|
|
354
|
+
vi.spyOn(sqliteDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
355
|
+
if (String(sql) !== 'COMMIT') {
|
|
356
|
+
return run(sql, ...params);
|
|
357
|
+
}
|
|
358
|
+
await run(sql, ...params);
|
|
359
|
+
throw new Error('commit succeeded but response was lost');
|
|
360
|
+
});
|
|
361
|
+
await expect(store.addDocument(createDocumentWithContent(url, 'Replacement', 'new ambiguous commit content'), { tags: ['new-tag'] })).rejects.toThrow('commit succeeded but response was lost');
|
|
362
|
+
await expect(store.getDocument(url)).resolves.toMatchObject({ title: 'Replacement', tags: ['new-tag'] });
|
|
363
|
+
await expect(storedContents(store, url)).resolves.toEqual(['new ambiguous commit content']);
|
|
364
|
+
});
|
|
365
|
+
it('retries a search that overlaps publication cleanup', async () => {
|
|
366
|
+
const url = 'https://example.com/search-publication-race';
|
|
367
|
+
const original = createDocumentWithContent(url, 'Original', 'old search race content');
|
|
368
|
+
await store.addDocument(original);
|
|
369
|
+
const internals = replacementInternals();
|
|
370
|
+
const publication = blockPublication();
|
|
371
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new search race content');
|
|
372
|
+
const replacementPromise = store.addDocument(replacement);
|
|
373
|
+
await publication.reached;
|
|
374
|
+
const getVisibility = internals.getJournalVisibilityFilter.bind(internals);
|
|
375
|
+
const visibilityCaptured = deferred();
|
|
376
|
+
const searchReleased = deferred();
|
|
377
|
+
vi.spyOn(internals, 'getJournalVisibilityFilter').mockImplementationOnce(async () => {
|
|
378
|
+
const filter = await getVisibility();
|
|
379
|
+
visibilityCaptured.resolve();
|
|
380
|
+
await searchReleased.promise;
|
|
381
|
+
return filter;
|
|
382
|
+
});
|
|
383
|
+
const searchPromise = storedContents(store, url);
|
|
384
|
+
await visibilityCaptured.promise;
|
|
385
|
+
publication.release();
|
|
386
|
+
await replacementPromise;
|
|
387
|
+
searchReleased.resolve();
|
|
388
|
+
await expect(searchPromise).resolves.toEqual(['new search race content']);
|
|
389
|
+
});
|
|
390
|
+
it('retries a search that read visibility before replacement preparation', async () => {
|
|
391
|
+
const url = 'https://example.com/search-preparation-race';
|
|
392
|
+
const original = createDocumentWithContent(url, 'Original', 'old preparation race content');
|
|
393
|
+
await store.addDocument(original);
|
|
394
|
+
const internals = replacementInternals();
|
|
395
|
+
const getVisibility = internals.getJournalVisibilityFilter.bind(internals);
|
|
396
|
+
const visibilityCaptured = deferred();
|
|
397
|
+
const searchReleased = deferred();
|
|
398
|
+
vi.spyOn(internals, 'getJournalVisibilityFilter').mockImplementationOnce(async () => {
|
|
399
|
+
const filter = await getVisibility();
|
|
400
|
+
visibilityCaptured.resolve();
|
|
401
|
+
await searchReleased.promise;
|
|
402
|
+
return filter;
|
|
403
|
+
});
|
|
404
|
+
const searchPromise = storedContents(store, url);
|
|
405
|
+
await visibilityCaptured.promise;
|
|
406
|
+
const publication = blockPublication();
|
|
407
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new preparation race content');
|
|
408
|
+
const replacementPromise = store.addDocument(replacement);
|
|
409
|
+
await publication.reached;
|
|
410
|
+
searchReleased.resolve();
|
|
411
|
+
await expect(searchPromise).resolves.toEqual(['old preparation race content']);
|
|
412
|
+
publication.release();
|
|
413
|
+
await replacementPromise;
|
|
414
|
+
await expect(storedContents(store, url)).resolves.toEqual(['new preparation race content']);
|
|
415
|
+
});
|
|
416
|
+
it('survives consecutive visibility invalidations from two replacements', async () => {
|
|
417
|
+
const firstUrl = 'https://example.com/consecutive-race-a';
|
|
418
|
+
const secondUrl = 'https://example.com/consecutive-race-b';
|
|
419
|
+
const first = createDocumentWithContent(firstUrl, 'First', 'old consecutive invalidation A');
|
|
420
|
+
const second = createDocumentWithContent(secondUrl, 'Second', 'old consecutive invalidation B');
|
|
421
|
+
await store.addDocument(first);
|
|
422
|
+
await store.addDocument(second);
|
|
423
|
+
const internals = replacementInternals();
|
|
424
|
+
const getVisibility = internals.getJournalVisibilityFilter.bind(internals);
|
|
425
|
+
const firstAttemptCaptured = deferred();
|
|
426
|
+
const firstAttemptReleased = deferred();
|
|
427
|
+
const secondAttemptCaptured = deferred();
|
|
428
|
+
const secondAttemptReleased = deferred();
|
|
429
|
+
let attempt = 0;
|
|
430
|
+
vi.spyOn(internals, 'getJournalVisibilityFilter').mockImplementation(async () => {
|
|
431
|
+
const filter = await getVisibility();
|
|
432
|
+
attempt++;
|
|
433
|
+
if (attempt === 1) {
|
|
434
|
+
firstAttemptCaptured.resolve();
|
|
435
|
+
await firstAttemptReleased.promise;
|
|
436
|
+
}
|
|
437
|
+
else if (attempt === 2) {
|
|
438
|
+
secondAttemptCaptured.resolve();
|
|
439
|
+
await secondAttemptReleased.promise;
|
|
440
|
+
}
|
|
441
|
+
return filter;
|
|
442
|
+
});
|
|
443
|
+
const queryVector = await mockEmbeddings.embed('consecutive invalidation');
|
|
444
|
+
const searchPromise = store.searchDocuments(queryVector, { limit: 100 });
|
|
445
|
+
await firstAttemptCaptured.promise;
|
|
446
|
+
const firstReplacement = createDocumentWithContent(firstUrl, 'First replacement', 'new consecutive invalidation A');
|
|
447
|
+
await store.addDocument(firstReplacement);
|
|
448
|
+
firstAttemptReleased.resolve();
|
|
449
|
+
await secondAttemptCaptured.promise;
|
|
450
|
+
const secondReplacement = createDocumentWithContent(secondUrl, 'Second replacement', 'new consecutive invalidation B');
|
|
451
|
+
await store.addDocument(secondReplacement);
|
|
452
|
+
secondAttemptReleased.resolve();
|
|
453
|
+
const contents = (await searchPromise).map((result) => result.content).sort();
|
|
454
|
+
expect(contents).toEqual(['new consecutive invalidation A', 'new consecutive invalidation B']);
|
|
455
|
+
});
|
|
456
|
+
it('uses committed visibility to invalidate another store instance cache', async () => {
|
|
457
|
+
const url = 'https://example.com/cross-instance-race';
|
|
458
|
+
const original = createDocumentWithContent(url, 'Original', 'old cross instance content');
|
|
459
|
+
await store.addDocument(original);
|
|
460
|
+
const readerStore = await openPeerStore();
|
|
461
|
+
expect(await storedContents(readerStore, url)).toEqual(['old cross instance content']);
|
|
462
|
+
const publication = blockPublication();
|
|
463
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new cross instance content');
|
|
464
|
+
const replacementPromise = store.addDocument(replacement);
|
|
465
|
+
await publication.reached;
|
|
466
|
+
expect(await storedContents(readerStore, url)).toEqual(['old cross instance content']);
|
|
467
|
+
publication.release();
|
|
468
|
+
await replacementPromise;
|
|
469
|
+
expect(await storedContents(readerStore, url)).toEqual(['new cross instance content']);
|
|
470
|
+
});
|
|
471
|
+
it('guarantees a retry after a slow query is invalidated', async () => {
|
|
472
|
+
const url = 'https://example.com/slow-invalidated-search';
|
|
473
|
+
const original = createDocumentWithContent(url, 'Original', 'slow invalidated search content');
|
|
474
|
+
await store.addDocument(original);
|
|
475
|
+
let now = 0;
|
|
476
|
+
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
477
|
+
const internals = replacementInternals();
|
|
478
|
+
const getVisibility = internals.getJournalVisibilityFilter.bind(internals);
|
|
479
|
+
const firstAttemptCaptured = deferred();
|
|
480
|
+
const firstAttemptReleased = deferred();
|
|
481
|
+
let attempts = 0;
|
|
482
|
+
vi.spyOn(internals, 'getJournalVisibilityFilter').mockImplementation(async () => {
|
|
483
|
+
const filter = await getVisibility();
|
|
484
|
+
attempts++;
|
|
485
|
+
if (attempts === 1) {
|
|
486
|
+
firstAttemptCaptured.resolve();
|
|
487
|
+
await firstAttemptReleased.promise;
|
|
488
|
+
}
|
|
489
|
+
return filter;
|
|
490
|
+
});
|
|
491
|
+
const queryVector = await mockEmbeddings.embed('slow invalidated search');
|
|
492
|
+
const searchPromise = store.searchDocuments(queryVector, { limit: 10 });
|
|
493
|
+
await firstAttemptCaptured.promise;
|
|
494
|
+
now = 10_000;
|
|
495
|
+
await store.createCollection('search-version-invalidation');
|
|
496
|
+
firstAttemptReleased.resolve();
|
|
497
|
+
await expect(searchPromise).resolves.toHaveLength(1);
|
|
498
|
+
expect(attempts).toBeGreaterThanOrEqual(2);
|
|
499
|
+
nowSpy.mockRestore();
|
|
500
|
+
});
|
|
501
|
+
it('serializes two store instances replacing the same URL', async () => {
|
|
502
|
+
const url = 'https://example.com/same-url-writers';
|
|
503
|
+
const original = createDocumentWithContent(url, 'Original', 'old same URL test content');
|
|
504
|
+
await store.addDocument(original, { tags: ['original-tag'] });
|
|
505
|
+
const contender = await openPeerStore();
|
|
506
|
+
const firstStage = blockNextLanceAdd();
|
|
507
|
+
const first = createDocumentWithContent(url, 'First replacement', 'first same URL test content');
|
|
508
|
+
const firstPromise = store.addDocument(first, { tags: ['first-tag'] });
|
|
509
|
+
await firstStage.staged;
|
|
510
|
+
const contenderWaiting = waitForLeaseContention(contender);
|
|
511
|
+
const second = createDocumentWithContent(url, 'Second replacement', 'second same URL test content');
|
|
512
|
+
const secondPromise = contender.addDocument(second, { tags: ['Second-Tag', 'second-tag'] });
|
|
513
|
+
await contenderWaiting;
|
|
514
|
+
expect(await storedContents(contender, url)).toEqual(['old same URL test content']);
|
|
515
|
+
expect(await contender.getDocument(url)).toMatchObject({ title: 'Original', tags: ['original-tag'] });
|
|
516
|
+
firstStage.release();
|
|
517
|
+
await Promise.all([firstPromise, secondPromise]);
|
|
518
|
+
expect(await contender.getDocument(url)).toMatchObject({ title: 'Second replacement', tags: ['second-tag'] });
|
|
519
|
+
expect(await storedContents(contender, url)).toEqual(['second same URL test content']);
|
|
520
|
+
const table = replacementInternals(contender).lanceTable;
|
|
521
|
+
await table.checkoutLatest();
|
|
522
|
+
const rows = await table.query().where(`url = '${url}'`).toArray();
|
|
523
|
+
expect(rows).toHaveLength(1);
|
|
524
|
+
});
|
|
525
|
+
it('does not recover a live replacement during another store initialization', async () => {
|
|
526
|
+
const url = 'https://example.com/live-initialization';
|
|
527
|
+
const original = createDocumentWithContent(url, 'Original', 'old live initialization test content');
|
|
528
|
+
await store.addDocument(original);
|
|
529
|
+
const stage = blockNextLanceAdd();
|
|
530
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new live initialization test content');
|
|
531
|
+
const replacementPromise = store.addDocument(replacement);
|
|
532
|
+
await stage.staged;
|
|
533
|
+
const initializingStore = await openPeerStore();
|
|
534
|
+
expect(await storedContents(initializingStore, url)).toEqual(['old live initialization test content']);
|
|
535
|
+
stage.release();
|
|
536
|
+
await replacementPromise;
|
|
537
|
+
expect(await storedContents(initializingStore, url)).toEqual(['new live initialization test content']);
|
|
538
|
+
});
|
|
539
|
+
it('renews the lease while Lance staging remains in flight', async () => {
|
|
540
|
+
const url = 'https://example.com/heartbeat-renewal';
|
|
541
|
+
await store.addDocument(createTestDocument(url, 'Original'));
|
|
542
|
+
vi.useFakeTimers();
|
|
543
|
+
try {
|
|
544
|
+
const leaseDb = replacementInternals().sqliteLeaseDb;
|
|
545
|
+
const run = leaseDb.run.bind(leaseDb);
|
|
546
|
+
const renewed = deferred();
|
|
547
|
+
vi.spyOn(leaseDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
548
|
+
const result = await run(sql, ...params);
|
|
549
|
+
if (String(sql).includes('SET lease_expires_at = ?') && String(sql).includes('state = ?')) {
|
|
550
|
+
renewed.resolve();
|
|
551
|
+
}
|
|
552
|
+
return result;
|
|
553
|
+
});
|
|
554
|
+
const stage = blockNextLanceAdd();
|
|
555
|
+
const replacementPromise = store.addDocument(createTestDocument(url, 'Replacement'));
|
|
556
|
+
await stage.staged;
|
|
557
|
+
const before = await leaseDb.get('SELECT lease_expires_at FROM document_replacements WHERE url = ?', [
|
|
558
|
+
url,
|
|
559
|
+
]);
|
|
560
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
561
|
+
await renewed.promise;
|
|
562
|
+
const after = await leaseDb.get('SELECT lease_expires_at FROM document_replacements WHERE url = ?', [
|
|
563
|
+
url,
|
|
564
|
+
]);
|
|
565
|
+
expect(after.lease_expires_at).toBeGreaterThan(before.lease_expires_at);
|
|
566
|
+
stage.release();
|
|
567
|
+
await replacementPromise;
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
vi.useRealTimers();
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
it('prevents a stale owner from publishing or deleting its successor lease', async () => {
|
|
574
|
+
const url = 'https://example.com/stale-owner';
|
|
575
|
+
const original = createDocumentWithContent(url, 'Original', 'old stale owner test content');
|
|
576
|
+
await store.addDocument(original);
|
|
577
|
+
const stage = blockNextLanceAdd();
|
|
578
|
+
const replacement = createDocumentWithContent(url, 'Stale replacement', 'stale replacement test content');
|
|
579
|
+
const replacementPromise = store.addDocument(replacement);
|
|
580
|
+
await stage.staged;
|
|
581
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
582
|
+
const journal = await sqliteDb.get('SELECT generation FROM document_replacements WHERE url = ?', [url]);
|
|
583
|
+
await sqliteDb.run('UPDATE document_replacements SET owner_id = ?, lease_expires_at = ? WHERE url = ?', [
|
|
584
|
+
'successor-owner',
|
|
585
|
+
Date.now() + 60_000,
|
|
586
|
+
url,
|
|
587
|
+
]);
|
|
588
|
+
stage.release();
|
|
589
|
+
await expect(replacementPromise).rejects.toThrow(`Replacement lease lost for ${url}`);
|
|
590
|
+
expect(await sqliteDb.get('SELECT owner_id, state FROM document_replacements WHERE url = ?', [url])).toMatchObject({
|
|
591
|
+
owner_id: 'successor-owner',
|
|
592
|
+
state: 'prepared',
|
|
593
|
+
});
|
|
594
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
595
|
+
expect(await storedContents(store, url)).toEqual(['old stale owner test content']);
|
|
596
|
+
const table = replacementInternals().lanceTable;
|
|
597
|
+
expect(await table.countRows(`url = '${url}' AND generation = '${journal.generation}'`)).toBe(0);
|
|
598
|
+
await sqliteDb.run('DELETE FROM document_replacements WHERE url = ?', [url]);
|
|
599
|
+
});
|
|
600
|
+
it('keeps a late stale append hidden and reaps it during recovery', async () => {
|
|
601
|
+
const url = 'https://example.com/late-stale-append';
|
|
602
|
+
const original = createDocumentWithContent(url, 'Original', 'old late append test content');
|
|
603
|
+
await store.addDocument(original);
|
|
604
|
+
const successor = await openPeerStore();
|
|
605
|
+
const staleTable = replacementInternals().lanceTable;
|
|
606
|
+
const add = staleTable.add.bind(staleTable);
|
|
607
|
+
const appendStarted = deferred();
|
|
608
|
+
const appendReleased = deferred();
|
|
609
|
+
vi.spyOn(staleTable, 'add').mockImplementationOnce(async (data, options) => {
|
|
610
|
+
appendStarted.resolve();
|
|
611
|
+
await appendReleased.promise;
|
|
612
|
+
return add(data, options);
|
|
613
|
+
});
|
|
614
|
+
const stale = createDocumentWithContent(url, 'Stale', 'stale late append test content');
|
|
615
|
+
const stalePromise = store.addDocument(stale);
|
|
616
|
+
await appendStarted.promise;
|
|
617
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
618
|
+
const staleJournal = await sqliteDb.get('SELECT generation FROM document_replacements WHERE url = ?', [url]);
|
|
619
|
+
await sqliteDb.run('UPDATE document_replacements SET lease_expires_at = 0 WHERE url = ?', [url]);
|
|
620
|
+
const winner = createDocumentWithContent(url, 'Winner', 'winner late append test content');
|
|
621
|
+
await successor.addDocument(winner);
|
|
622
|
+
const deleteRows = staleTable.delete.bind(staleTable);
|
|
623
|
+
vi.spyOn(staleTable, 'delete').mockImplementation(async (predicate) => {
|
|
624
|
+
if (predicate.includes(staleJournal.generation)) {
|
|
625
|
+
throw new Error('injected stale cleanup failure');
|
|
626
|
+
}
|
|
627
|
+
return deleteRows(predicate);
|
|
628
|
+
});
|
|
629
|
+
appendReleased.resolve();
|
|
630
|
+
await expect(stalePromise).rejects.toThrow(`Replacement lease lost for ${url}`);
|
|
631
|
+
const winnerTable = replacementInternals(successor).lanceTable;
|
|
632
|
+
await winnerTable.checkoutLatest();
|
|
633
|
+
const rows = await winnerTable.query().where(`url = '${url}'`).toArray();
|
|
634
|
+
expect(rows.some((row) => row.generation === staleJournal.generation && row.published === false)).toBe(true);
|
|
635
|
+
expect(await storedContents(successor, url)).toEqual(['winner late append test content']);
|
|
636
|
+
const recoveredStore = await openPeerStore();
|
|
637
|
+
const recoveredTable = replacementInternals(recoveredStore).lanceTable;
|
|
638
|
+
await recoveredTable.checkoutLatest();
|
|
639
|
+
expect(await recoveredTable.countRows(`url = '${url}' AND generation = '${staleJournal.generation}'`)).toBe(0);
|
|
640
|
+
expect(await storedContents(recoveredStore, url)).toEqual(['winner late append test content']);
|
|
641
|
+
});
|
|
642
|
+
it.each(['published', 'deleting'])('does not let stale %s cleanup delete a successor generation', async (state) => {
|
|
643
|
+
const url = `https://example.com/stale-${state}-cleanup`;
|
|
644
|
+
await store.addDocument(createTestDocument(url, 'Original'));
|
|
645
|
+
const successor = await openPeerStore();
|
|
646
|
+
const staleTable = replacementInternals().lanceTable;
|
|
647
|
+
const deleteRows = staleTable.delete.bind(staleTable);
|
|
648
|
+
const cleanupStarted = deferred();
|
|
649
|
+
const cleanupReleased = deferred();
|
|
650
|
+
vi.spyOn(staleTable, 'delete').mockImplementationOnce(async (predicate) => {
|
|
651
|
+
cleanupStarted.resolve();
|
|
652
|
+
await cleanupReleased.promise;
|
|
653
|
+
return deleteRows(predicate);
|
|
654
|
+
});
|
|
655
|
+
const cleanupPromise = state === 'published'
|
|
656
|
+
? store.addDocument(createDocumentWithContent(url, 'Stale replacement', 'stale published cleanup content'))
|
|
657
|
+
: store.deleteDocument(url);
|
|
658
|
+
await cleanupStarted.promise;
|
|
659
|
+
await replacementInternals().sqliteDb.run('UPDATE document_replacements SET lease_expires_at = 0 WHERE url = ?', [url]);
|
|
660
|
+
const winnerContent = `winner ${state} cleanup content`;
|
|
661
|
+
const winner = createDocumentWithContent(url, 'Winner', winnerContent);
|
|
662
|
+
await successor.addDocument(winner);
|
|
663
|
+
cleanupReleased.resolve();
|
|
664
|
+
await cleanupPromise;
|
|
665
|
+
expect(await successor.getDocument(url)).toMatchObject({ title: 'Winner' });
|
|
666
|
+
expect(await storedContents(successor, url)).toEqual([winnerContent]);
|
|
667
|
+
});
|
|
668
|
+
it('cancels a contender while it waits for a live same-URL lease', async () => {
|
|
669
|
+
const url = 'https://example.com/cancelled-lease-wait';
|
|
670
|
+
await store.addDocument(createTestDocument(url, 'Original'));
|
|
671
|
+
const contender = await openPeerStore();
|
|
672
|
+
const stage = blockNextLanceAdd();
|
|
673
|
+
const activePromise = store.addDocument(createTestDocument(url, 'Active replacement'));
|
|
674
|
+
await stage.staged;
|
|
675
|
+
const waiting = waitForLeaseContention(contender);
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
const waitingPromise = contender.addDocument(createTestDocument(url, 'Cancelled replacement'), { signal: controller.signal });
|
|
678
|
+
await waiting;
|
|
679
|
+
controller.abort();
|
|
680
|
+
await expect(waitingPromise).rejects.toMatchObject({ name: 'AbortError' });
|
|
681
|
+
stage.release();
|
|
682
|
+
await activePromise;
|
|
683
|
+
expect(await contender.getDocument(url)).toMatchObject({ title: 'Active replacement' });
|
|
684
|
+
});
|
|
685
|
+
it('serializes add then delete across two store instances', async () => {
|
|
686
|
+
const url = 'https://example.com/add-then-delete';
|
|
687
|
+
await store.addDocument(createTestDocument(url, 'Original'));
|
|
688
|
+
const deletingStore = await openPeerStore();
|
|
689
|
+
const stage = blockNextLanceAdd();
|
|
690
|
+
const addPromise = store.addDocument(createTestDocument(url, 'Replacement'));
|
|
691
|
+
await stage.staged;
|
|
692
|
+
const deleteWaiting = waitForLeaseContention(deletingStore);
|
|
693
|
+
const deletePromise = deletingStore.deleteDocument(url);
|
|
694
|
+
await deleteWaiting;
|
|
695
|
+
expect(await deletingStore.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
696
|
+
stage.release();
|
|
697
|
+
await Promise.all([addPromise, deletePromise]);
|
|
698
|
+
expect(await deletingStore.getDocument(url)).toBeNull();
|
|
699
|
+
expect(await storedContents(deletingStore, url)).toEqual([]);
|
|
700
|
+
const table = replacementInternals(deletingStore).lanceTable;
|
|
701
|
+
await table.checkoutLatest();
|
|
702
|
+
expect(await table.countRows(`url = '${url}'`)).toBe(0);
|
|
703
|
+
});
|
|
704
|
+
it('serializes delete then add across two store instances', async () => {
|
|
705
|
+
const url = 'https://example.com/delete-then-add';
|
|
706
|
+
await store.addDocument(createTestDocument(url, 'Original'));
|
|
707
|
+
const addingStore = await openPeerStore();
|
|
708
|
+
const sqliteDb = replacementInternals().sqliteDb;
|
|
709
|
+
const runSql = sqliteDb.run.bind(sqliteDb);
|
|
710
|
+
const deleteReady = deferred();
|
|
711
|
+
const deleteReleased = deferred();
|
|
712
|
+
vi.spyOn(sqliteDb, 'run').mockImplementation(async (sql, ...params) => {
|
|
713
|
+
if (String(sql) === 'BEGIN TRANSACTION') {
|
|
714
|
+
deleteReady.resolve();
|
|
715
|
+
await deleteReleased.promise;
|
|
716
|
+
}
|
|
717
|
+
return runSql(sql, ...params);
|
|
718
|
+
});
|
|
719
|
+
const deletePromise = store.deleteDocument(url);
|
|
720
|
+
await deleteReady.promise;
|
|
721
|
+
const addWaiting = waitForLeaseContention(addingStore);
|
|
722
|
+
const winner = createDocumentWithContent(url, 'Winner', 'winner delete then add test content');
|
|
723
|
+
const addPromise = addingStore.addDocument(winner);
|
|
724
|
+
await addWaiting;
|
|
725
|
+
expect(await addingStore.getDocument(url)).toMatchObject({ title: 'Original' });
|
|
726
|
+
deleteReleased.resolve();
|
|
727
|
+
await Promise.all([deletePromise, addPromise]);
|
|
728
|
+
expect(await addingStore.getDocument(url)).toMatchObject({ title: 'Winner' });
|
|
729
|
+
expect(await storedContents(addingStore, url)).toEqual(['winner delete then add test content']);
|
|
730
|
+
const table = replacementInternals(addingStore).lanceTable;
|
|
731
|
+
await table.checkoutLatest();
|
|
732
|
+
expect(await table.countRows(`url = '${url}' AND published = true`)).toBe(1);
|
|
733
|
+
});
|
|
734
|
+
it('keeps the published generation visible, then recovers its cleanup after lease expiry', async () => {
|
|
735
|
+
const url = 'https://example.com/published-cleanup-failure';
|
|
736
|
+
const original = createDocumentWithContent(url, 'Original', 'old published cleanup content');
|
|
737
|
+
await store.addDocument(original);
|
|
738
|
+
const replacement = createDocumentWithContent(url, 'Replacement', 'new published cleanup content');
|
|
739
|
+
vi.spyOn(replacementInternals(), 'finishDocumentReplacement').mockRejectedValueOnce(new Error('injected cleanup failure'));
|
|
740
|
+
await expect(store.addDocument(replacement)).resolves.toBeUndefined();
|
|
741
|
+
expect(await store.getDocument(url)).toMatchObject({ title: 'Replacement' });
|
|
742
|
+
expect(await storedContents(store, url)).toEqual(['new published cleanup content']);
|
|
743
|
+
expect(await replacementInternals().sqliteDb.get('SELECT state FROM document_replacements WHERE url = ?', [url])).toMatchObject({
|
|
744
|
+
state: 'published',
|
|
745
|
+
});
|
|
746
|
+
await replacementInternals().sqliteDb.run('UPDATE document_replacements SET lease_expires_at = 0 WHERE url = ?', [url]);
|
|
747
|
+
const recoveredStore = await openPeerStore();
|
|
748
|
+
expect(await storedContents(recoveredStore, url)).toEqual(['new published cleanup content']);
|
|
749
|
+
const journal = await replacementInternals(recoveredStore).sqliteDb.get('SELECT url FROM document_replacements WHERE url = ?', [
|
|
750
|
+
url,
|
|
751
|
+
]);
|
|
752
|
+
expect(journal).toBeUndefined();
|
|
753
|
+
});
|
|
754
|
+
it('rejects a malformed durable cleanup generation list', () => {
|
|
755
|
+
expect(() => replacementInternals().parseCleanupGenerations('{bad json')).toThrow();
|
|
756
|
+
});
|
|
757
|
+
});
|
|
107
758
|
describe('getDocument', () => {
|
|
108
759
|
it('should return null for non-existent document', async () => {
|
|
109
760
|
const result = await store.getDocument('https://nonexistent.com/page');
|
|
@@ -118,6 +769,27 @@ describe('DocumentStore', () => {
|
|
|
118
769
|
expect(result?.title).toBe('Get Test');
|
|
119
770
|
expect(result?.lastIndexed).toBeInstanceOf(Date);
|
|
120
771
|
});
|
|
772
|
+
it('hydrates metadata and tags in one query for every document read API', async () => {
|
|
773
|
+
const url = 'https://example.com/snapshot-tags';
|
|
774
|
+
await store.addDocument(createTestDocument(url, 'Snapshot Tags'), { tags: ['tag'] });
|
|
775
|
+
await store.createCollection('Snapshot Collection');
|
|
776
|
+
await store.addToCollection('Snapshot Collection', [url]);
|
|
777
|
+
const reader = replacementInternals().sqliteReadDb;
|
|
778
|
+
const get = vi.spyOn(reader, 'get');
|
|
779
|
+
const all = vi.spyOn(reader, 'all');
|
|
780
|
+
get.mockClear();
|
|
781
|
+
await store.getDocument(url);
|
|
782
|
+
expect(String(get.mock.calls[0]?.[0])).toContain('json_group_array');
|
|
783
|
+
expect(get).toHaveBeenCalledOnce();
|
|
784
|
+
all.mockClear();
|
|
785
|
+
await store.listDocuments();
|
|
786
|
+
expect(String(all.mock.calls[0]?.[0])).toContain('json_group_array');
|
|
787
|
+
expect(all).toHaveBeenCalledOnce();
|
|
788
|
+
all.mockClear();
|
|
789
|
+
await store.getCollection('Snapshot Collection');
|
|
790
|
+
expect(String(all.mock.calls[0]?.[0])).toContain('json_group_array');
|
|
791
|
+
expect(all).toHaveBeenCalledOnce();
|
|
792
|
+
});
|
|
121
793
|
});
|
|
122
794
|
describe('listDocuments', () => {
|
|
123
795
|
it('should return empty array when no documents', async () => {
|
|
@@ -158,6 +830,17 @@ describe('DocumentStore', () => {
|
|
|
158
830
|
doc = await store.getDocument(url);
|
|
159
831
|
expect(doc).toBeNull();
|
|
160
832
|
});
|
|
833
|
+
it('keeps failed vector cleanup hidden behind a deleting journal', async () => {
|
|
834
|
+
const url = 'https://example.com/delete-cleanup-failure';
|
|
835
|
+
await store.addDocument(createTestDocument(url, 'Delete cleanup failure'));
|
|
836
|
+
vi.spyOn(replacementInternals().lanceTable, 'delete').mockRejectedValueOnce(new Error('injected delete cleanup failure'));
|
|
837
|
+
await expect(store.deleteDocument(url)).resolves.toBeUndefined();
|
|
838
|
+
expect(await store.getDocument(url)).toBeNull();
|
|
839
|
+
expect(await storedContents(store, url)).toEqual([]);
|
|
840
|
+
expect(await replacementInternals().sqliteReadDb.get('SELECT state FROM document_replacements WHERE url = ?', [url])).toMatchObject({
|
|
841
|
+
state: 'deleting',
|
|
842
|
+
});
|
|
843
|
+
});
|
|
161
844
|
it('should not throw when deleting non-existent document', async () => {
|
|
162
845
|
await expect(store.deleteDocument('https://nonexistent.com/page')).resolves.not.toThrow();
|
|
163
846
|
});
|
|
@@ -324,8 +1007,7 @@ describe('DocumentStore', () => {
|
|
|
324
1007
|
});
|
|
325
1008
|
it('should apply migrations only once', async () => {
|
|
326
1009
|
// Create a second store instance pointing to the same database
|
|
327
|
-
const store2 =
|
|
328
|
-
await store2.initialize();
|
|
1010
|
+
const store2 = await openPeerStore();
|
|
329
1011
|
// Add document with auth fields using new store
|
|
330
1012
|
const doc = createTestDocument('https://second-store.com', 'Second Store Test');
|
|
331
1013
|
doc.metadata.requiresAuth = true;
|
|
@@ -334,6 +1016,43 @@ describe('DocumentStore', () => {
|
|
|
334
1016
|
const retrieved = await store2.getDocument('https://second-store.com');
|
|
335
1017
|
expect(retrieved?.requiresAuth).toBe(true);
|
|
336
1018
|
});
|
|
1019
|
+
it('should add generations to a legacy Lance table without losing its chunks', async () => {
|
|
1020
|
+
const url = 'https://example.com/legacy-lance';
|
|
1021
|
+
const original = createDocumentWithContent(url, 'Legacy', 'legacy table content');
|
|
1022
|
+
await store.addDocument(original);
|
|
1023
|
+
await replacementInternals().lanceTable.dropColumns(['generation', 'published']);
|
|
1024
|
+
const migratedStore = await openPeerStore();
|
|
1025
|
+
expect(await storedContents(migratedStore, url)).toEqual(['legacy table content']);
|
|
1026
|
+
const migratedSchema = await replacementInternals(migratedStore).lanceTable.schema();
|
|
1027
|
+
const fields = migratedSchema.fields.map((field) => field.name);
|
|
1028
|
+
expect(fields).toContain('generation');
|
|
1029
|
+
expect(fields).toContain('published');
|
|
1030
|
+
expect(migratedSchema.fields.find((field) => field.name === 'published')?.nullable).toBe(false);
|
|
1031
|
+
const replacement = createDocumentWithContent(url, 'Migrated', 'migrated table content');
|
|
1032
|
+
await migratedStore.addDocument(replacement);
|
|
1033
|
+
expect(await storedContents(migratedStore, url)).toEqual(['migrated table content']);
|
|
1034
|
+
});
|
|
1035
|
+
it('allows concurrent initializers to finish the same journal and generation migrations', async () => {
|
|
1036
|
+
const url = 'https://example.com/concurrent-migration';
|
|
1037
|
+
await store.addDocument(createTestDocument(url, 'Concurrent migration'));
|
|
1038
|
+
await replacementInternals().lanceTable.dropColumns(['generation', 'published']);
|
|
1039
|
+
await replacementInternals().sqliteDb.exec(`
|
|
1040
|
+
DROP TABLE document_replacements;
|
|
1041
|
+
DELETE FROM schema_migrations WHERE version = 5;
|
|
1042
|
+
`);
|
|
1043
|
+
await store.close();
|
|
1044
|
+
const first = new DocumentStore(join(tempDir, 'docs.db'), join(tempDir, 'vectors'), mockEmbeddings, 100);
|
|
1045
|
+
const second = new DocumentStore(join(tempDir, 'docs.db'), join(tempDir, 'vectors'), mockEmbeddings, 100);
|
|
1046
|
+
openStores.add(first);
|
|
1047
|
+
openStores.add(second);
|
|
1048
|
+
await Promise.all([first.initialize(), second.initialize()]);
|
|
1049
|
+
const firstFields = (await replacementInternals(first).lanceTable.schema()).fields.map((field) => field.name);
|
|
1050
|
+
const secondFields = (await replacementInternals(second).lanceTable.schema()).fields.map((field) => field.name);
|
|
1051
|
+
for (const fields of [firstFields, secondFields]) {
|
|
1052
|
+
expect(fields).toEqual(expect.arrayContaining(['generation', 'published']));
|
|
1053
|
+
}
|
|
1054
|
+
expect(await replacementInternals(first).sqliteReadDb.get('SELECT COUNT(*) AS count FROM schema_migrations WHERE version = 5')).toMatchObject({ count: 1 });
|
|
1055
|
+
});
|
|
337
1056
|
it('should handle auth columns added by migration', async () => {
|
|
338
1057
|
// Test that the migration added the requires_auth and auth_domain columns
|
|
339
1058
|
const docWithAuth = createTestDocument('https://auth-columns.com', 'Auth Columns Test');
|