Skip to content

Commit 457e37d

Browse files
committed
improvement(knowledge): validate stored chunk counts and scope inline reads to the base
- the bundle gate checks each document's stored chunk count against the format's per-document ceiling, so a base holding more chunks than a bundle can describe is refused up front rather than written into an invalid manifest - the inline payload read carries its knowledge base id, matching the chunk reads, so neither can reach a row outside the base being exported - an append is refused once the consumer has aborted, since a destroyed archive has no listener left to receive the error it would emit
1 parent b541b97 commit 457e37d

5 files changed

Lines changed: 44 additions & 14 deletions

File tree

apps/sim/lib/knowledge/application/exports.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const documents = [
8585
characterCount: 40,
8686
tags: {},
8787
file: { kind: 'storage', key: 'kb/handbook.pdf' },
88+
storedChunkCount: 2,
8889
hasChunks: true,
8990
},
9091
]
@@ -196,6 +197,19 @@ describe('exportKnowledgeBase', () => {
196197
expect(mocks.recordAudit).not.toHaveBeenCalled()
197198
})
198199

200+
/** The written manifest carries the streamed count, so the gate must check the stored one. */
201+
it('refuses a document holding more chunks than the bundle format describes', async () => {
202+
mocks.listDocuments.mockResolvedValueOnce([{ ...documents[0], storedChunkCount: 5_001 }])
203+
204+
await expect(
205+
exportKnowledgeBase.execute({
206+
principal,
207+
input: { knowledgeBaseId: 'knowledge-1', vectors: true },
208+
})
209+
).rejects.toMatchObject({ code: 'conflict' })
210+
expect(mocks.recordAudit).not.toHaveBeenCalled()
211+
})
212+
199213
it('refuses a stored tag definition the bundle format cannot describe', async () => {
200214
mocks.listTags.mockResolvedValueOnce([
201215
{ slot: 'tag1', displayName: 'Product', fieldType: 'mystery' },

apps/sim/lib/knowledge/application/exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const exportKnowledgeBase = defineAuthorizedKnowledgeUseCase({
7272
},
7373
tags,
7474
documents: documents.map((document) =>
75-
toManifestDocument(document, bundleEntryPaths(document), 0)
75+
toManifestDocument(document, bundleEntryPaths(document), document.storedChunkCount)
7676
),
7777
})
7878
return {

apps/sim/lib/knowledge/transfer/export-archive.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function exportableDocument(overrides: Partial<ExportableDocument>): ExportableD
4242
characterCount: 40,
4343
tags: { tag1: 'Billing' },
4444
file: { kind: 'storage', key: 'kb/handbook.pdf' },
45+
storedChunkCount: 2,
4546
hasChunks: true,
4647
...overrides,
4748
}
@@ -78,7 +79,7 @@ function bundle(overrides: Partial<KnowledgeBaseExportBundle> = {}): KnowledgeBa
7879
id: INLINE_ID,
7980
filename: 'note.txt',
8081
mimeType: 'text/plain',
81-
file: { kind: 'data-uri', documentId: INLINE_ID },
82+
file: { kind: 'data-uri', knowledgeBaseId: 'kb-1', documentId: INLINE_ID },
8283
hasChunks: false,
8384
}),
8485
exportableDocument({ id: TEXT_ONLY_ID, filename: 'wiki page', file: null, hasChunks: true }),

apps/sim/lib/knowledge/transfer/export-archive.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async function openFileSource(source: ExportableFileSource): Promise<Readable |
3939
return downloadFileStream({ key: source.key, context: 'knowledge-base' })
4040
}
4141
return decodeDataUriWithinLimit(
42-
await readInlineFileUrl(source.documentId),
42+
await readInlineFileUrl(source.knowledgeBaseId, source.documentId),
4343
MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE
4444
).buffer
4545
}
@@ -65,14 +65,20 @@ function toChunkLine(chunk: ExportableChunk, vectors: boolean): KnowledgeBundleC
6565
* entry at a time and emits `entry` exactly once per append, or `error` in its
6666
* place, which `once` turns into a rejection. A consumer that goes away
6767
* destroys the archive without either event, so `closed` aborts the wait and
68-
* the in-flight source is released instead of leaking.
68+
* the in-flight source is released instead of leaking; an append is refused
69+
* outright once it has fired, since a destroyed archive has no listener left
70+
* to receive the error it would emit.
6971
*/
7072
async function appendEntry(
7173
archive: ZipArchive,
7274
source: Readable | Buffer | string,
7375
name: string,
7476
closed: AbortSignal
7577
): Promise<void> {
78+
if (closed.aborted) {
79+
if (source instanceof Readable) source.destroy()
80+
closed.throwIfAborted()
81+
}
7682
const consumed = once(archive, 'entry', { signal: closed })
7783
archive.append(source, { name })
7884
try {

apps/sim/lib/knowledge/transfer/export-source.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@ const CHUNK_PAGE_SIZE = { text: 500, vectors: 100 } as const
2727
/** Where a document's original bytes come from, when it has any. */
2828
export type ExportableFileSource =
2929
| { kind: 'storage'; key: string }
30-
| { kind: 'data-uri'; documentId: string }
30+
| { kind: 'data-uri'; knowledgeBaseId: string; documentId: string }
3131

3232
export interface ExportableDocument extends ExportableDocumentRecord {
3333
file: ExportableFileSource | null
34+
/**
35+
* Chunks the document reports holding. The archive writes what its chunk
36+
* stream actually produced, which can only be lower; this is what the bundle
37+
* gate checks against the format's per-document ceiling before any byte streams.
38+
*/
39+
storedChunkCount: number
3440
/** True when the document finished processing and holds chunks worth exporting. */
3541
hasChunks: boolean
3642
}
@@ -50,13 +56,12 @@ function exportableDocumentCondition(knowledgeBaseId: string) {
5056
)
5157
}
5258

53-
function fileSourceFor(row: {
54-
id: string
55-
storageKey: string | null
56-
hasInlineFile: boolean
57-
}): ExportableFileSource | null {
59+
function fileSourceFor(
60+
knowledgeBaseId: string,
61+
row: { id: string; storageKey: string | null; hasInlineFile: boolean }
62+
): ExportableFileSource | null {
5863
if (row.storageKey) return { kind: 'storage', key: row.storageKey }
59-
if (row.hasInlineFile) return { kind: 'data-uri', documentId: row.id }
64+
if (row.hasInlineFile) return { kind: 'data-uri', knowledgeBaseId, documentId: row.id }
6065
return null
6166
}
6267

@@ -77,11 +82,14 @@ export async function listExportableTags(
7782
* reached: the column can hold megabytes per row, so the listing carries a flag
7883
* and the archive fetches one payload at a time.
7984
*/
80-
export async function readInlineFileUrl(documentId: string): Promise<string> {
85+
export async function readInlineFileUrl(
86+
knowledgeBaseId: string,
87+
documentId: string
88+
): Promise<string> {
8189
const [row] = await db
8290
.select({ fileUrl: document.fileUrl })
8391
.from(document)
84-
.where(eq(document.id, documentId))
92+
.where(and(eq(document.knowledgeBaseId, knowledgeBaseId), eq(document.id, documentId)))
8593
.limit(1)
8694
if (!row) throw new OrchestrationError('not_found', 'Document not found')
8795
return row.fileUrl
@@ -140,7 +148,7 @@ export async function listExportableDocuments(
140148

141149
const documents: ExportableDocument[] = []
142150
for (const row of rows) {
143-
const file = fileSourceFor(row)
151+
const file = fileSourceFor(knowledgeBaseId, row)
144152
const hasChunks = row.processingStatus === 'completed' && row.chunkCount > 0
145153
if (!file && !hasChunks) continue
146154
documents.push({
@@ -152,6 +160,7 @@ export async function listExportableDocuments(
152160
tokenCount: row.tokenCount,
153161
characterCount: row.characterCount,
154162
file,
163+
storedChunkCount: row.chunkCount,
155164
hasChunks,
156165
tags: Object.fromEntries(ALL_TAG_SLOTS.map((slot) => [slot, row[slot]])),
157166
})

0 commit comments

Comments
 (0)