Skip to content

Commit 42e4f5a

Browse files
committed
fix(knowledge): report what a bulk document sweep did, and refuse tag slots it cannot show
A bulk document update dropped ids that matched nothing and said so only in a server log, then answered a selection where nothing matched with a 404 — refusing a request that had nothing to refuse. It was never atomic, so there was no guarantee to preserve. It now reports unmatched ids the way the chunk sweep beside it always has, and a selection that matches nothing is the same answer as one that matches some of what was asked for. Its count is renamed to match that sibling, which also corrects it: the update returns every row it matched, so the old name promised a count of what changed. Uploading a document could write a value into a tag slot the knowledge base has not defined. Nothing could then read it back — every filter refuses an undefined tag, and the app renders a slot only when a definition covers it. Refused now rather than defined automatically: a slot write carries no display name, so inventing one would put a name the caller never chose into the vocabulary every later reader sees. Clearing such a slot still works, so anything already written can be removed. Reading an archived knowledge base answers not-found, which is deliberate — the same answer conceals a workspace the caller cannot reach, so the message cannot say more without telling the two apart. The parameter now says which lifecycle it addresses, which reaches both the reference and the terminal.
1 parent bcd9f2e commit 42e4f5a

13 files changed

Lines changed: 369 additions & 24 deletions

File tree

apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/collection.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => {
209209
mockBulkUpdate.mockResolvedValue({
210210
operation: 'disable',
211211
successCount: 2,
212+
errors: [],
212213
updatedDocuments: [
213214
{ id: 'doc-1', enabled: false },
214215
{ id: 'doc-2', enabled: false },
@@ -227,6 +228,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => {
227228
mockBulkUpdate.mockResolvedValueOnce({
228229
operation: 'disable',
229230
successCount: 100_000,
231+
errors: [],
230232
updatedDocuments: Array.from({ length: 100_000 }, (_, index) => ({
231233
id: `doc-${index}`,
232234
enabled: false,
@@ -241,7 +243,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => {
241243

242244
expect(response.status).toBe(200)
243245
expect(await response.json()).toEqual({
244-
data: { operation: 'disable', updatedCount: 100_000 },
246+
data: { operation: 'disable', processed: 100_000, errors: [] },
245247
})
246248
})
247249

@@ -257,7 +259,7 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents', () => {
257259

258260
expect(response.status).toBe(200)
259261
expect(await response.json()).toEqual({
260-
data: { operation: 'disable', updatedCount: 2, documentIds: ['doc-1', 'doc-2'] },
262+
data: { operation: 'disable', processed: 2, errors: [], documentIds: ['doc-1', 'doc-2'] },
261263
})
262264
expect(mockBulkUpdate).toHaveBeenCalledWith(
263265
expect.objectContaining({

apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,13 @@ export const PATCH = defineV2JsonRoute({
156156
* bounds. A `selectAll` request has no such bound: a knowledge base with
157157
* 100k documents would otherwise materialize and element-wise validate a
158158
* multi-megabyte identifier array nobody asked for. That caller reads
159-
* `updatedCount` and re-lists if it needs the identifiers.
159+
* `processed` and re-lists if it needs the identifiers.
160160
*/
161161
return {
162162
data: {
163163
operation: result.operation,
164-
updatedCount: result.successCount,
164+
processed: result.successCount,
165+
errors: result.errors,
165166
documentIds: result.selectAll
166167
? undefined
167168
: result.updatedDocuments.map((document) => document.id),

apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ const ALLOWED = new Map<string, string>([
4949
'ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.',
5050
'not touched here: lives in v2/knowledge.ts',
5151
],
52-
[
53-
'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.',
54-
'not touched here: lives in v2/knowledge.ts',
55-
],
5652
[
5753
'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.',
5854
'not touched here: lives in v2/knowledge.ts',

apps/sim/lib/api/contracts/v2/knowledge.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ export const v2ListKnowledgeBasesQuerySchema = z
627627
scope: v2KnowledgeBaseScopeSchema
628628
.default('active')
629629
.describe(
630-
'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.'
630+
'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a delete archived and the restore operation can bring back. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.'
631631
),
632632
folderPath: v2FolderPathInputSchema
633633
.optional()
@@ -755,10 +755,24 @@ export const v2CreateKnowledgeBaseContract = defineRouteContract({
755755
},
756756
})
757757

758+
/**
759+
* Reads the active lifecycle only, which is why the identifier redescribes
760+
* itself here rather than reusing the shared params schema: an archived
761+
* knowledge base answers `404` from this route the same way a nonexistent one
762+
* does, and nothing else on the read surface says which of the two lifecycles
763+
* it addresses. `GET /api/v2/knowledge?scope=archived` is what resolves an
764+
* archived base, and `POST /knowledge/{knowledgeBaseId}/restore` brings it back.
765+
*/
766+
const v2GetKnowledgeBaseParamsSchema = v2KnowledgeBaseParamsSchema.extend({
767+
knowledgeBaseId: v2KnowledgeBaseParamsSchema.shape.knowledgeBaseId.describe(
768+
'Knowledge base to read. Active knowledge bases only: an archived one answers 404 here, is listed by `scope=archived`, and is brought back by the restore endpoint.'
769+
),
770+
})
771+
758772
export const v2GetKnowledgeBaseContract = defineRouteContract({
759773
method: 'GET',
760774
path: '/api/v2/knowledge/[knowledgeBaseId]',
761-
params: v2KnowledgeBaseParamsSchema,
775+
params: v2GetKnowledgeBaseParamsSchema,
762776
query: v1KnowledgeWorkspaceQuerySchema
763777
.extend({
764778
workspaceId: v1KnowledgeWorkspaceQuerySchema.shape.workspaceId.describe(
@@ -1482,22 +1496,43 @@ export const v2BulkKnowledgeDocumentsBodySchema = z
14821496
})
14831497
export type V2BulkKnowledgeDocumentsBody = z.input<typeof v2BulkKnowledgeDocumentsBodySchema>
14841498

1485-
/** Bulk update outcome — one object, not a page. */
1499+
/**
1500+
* Bulk update outcome — one object, not a page.
1501+
*
1502+
* Best-effort, and reported the same way {@link v2BulkKnowledgeChunksDataSchema}
1503+
* reports it: an identifier naming no updatable document in the knowledge base
1504+
* lands in `errors` rather than failing the request, and a request whose every
1505+
* identifier missed answers `200` with `processed: 0` instead of `404`. The
1506+
* selection has always applied to what it matched, so the shared shape describes
1507+
* what the operation already did rather than changing it.
1508+
*
1509+
* `processed`, not `updatedCount`: the count is the documents the selection
1510+
* matched, and disabling a document that was already disabled counts it, so it
1511+
* is not a count of changes. That is the same quantity `processed` names on the
1512+
* chunk sibling, and naming it the same way keeps the two readable side by side.
1513+
*/
14861514
export const v2BulkKnowledgeDocumentsDataSchema = z
14871515
.object({
14881516
operation: z.enum(['enable', 'disable']).describe('Operation that was applied.'),
1489-
updatedCount: z
1517+
processed: z
14901518
.number()
14911519
.int()
14921520
.nonnegative()
1493-
.describe('Number of documents the operation changed.')
1521+
.describe(
1522+
'Number of documents in this knowledge base the operation matched. Documents already in the requested state are counted too, so this is not a count of changes.'
1523+
)
14941524
.meta({ examples: [42] }),
1525+
errors: z
1526+
.array(z.string())
1527+
.describe(
1528+
'Per-document failures, including any identifier that named no updatable document in the knowledge base. A populated array still answers 200, and a `selectAll` request reports an empty one.'
1529+
),
14951530
documentIds: z
14961531
.array(z.string())
14971532
.optional()
14981533
.describe(
1499-
'Identifiers of the documents the operation changed. Present only for an explicit `documentIds` request, which is bounded to ' +
1500-
`${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents; a \`selectAll\` request omits it because the selection is unbounded, and reports \`updatedCount\` instead.`
1534+
'Identifiers of the documents the operation matched. Present only for an explicit `documentIds` request, which is bounded to ' +
1535+
`${MAX_V2_BULK_KNOWLEDGE_DOCUMENTS} documents; a \`selectAll\` request omits it because the selection is unbounded, and reports \`processed\` instead.`
15011536
),
15021537
})
15031538
.strict()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { v2ListKnowledgeBasesQuerySchema } from '@/lib/api/contracts/v2/knowledge'
6+
import { v2ListTablesQuerySchema } from '@/lib/api/contracts/v2/tables'
7+
import { v2ListWorkflowsQuerySchema } from '@/lib/api/contracts/v2/workflows'
8+
9+
/**
10+
* A v2 description is read on two surfaces at once: the API reference, where a
11+
* filter is `folderPath`, and `sim tables list --help`, where the same filter is
12+
* `--folder`. Naming either spelling is wrong on the other surface, so the
13+
* `scope` prose names the concept — "the folder filter" — the way the workflows
14+
* sibling already does. Asserted on both so the pair cannot drift apart again.
15+
*/
16+
const SCOPE_DESCRIPTIONS = [
17+
['tables', v2ListTablesQuerySchema.shape.scope.description],
18+
['workflows', v2ListWorkflowsQuerySchema.shape.scope.description],
19+
['knowledge', v2ListKnowledgeBasesQuerySchema.shape.scope.description],
20+
] as const
21+
22+
describe('v2 list scope descriptions', () => {
23+
it.each(SCOPE_DESCRIPTIONS)(
24+
'name the folder filter transport-neutrally on %s',
25+
(_, described) => {
26+
expect(described).toBeTypeOf('string')
27+
expect(described).toContain('The folder filter resolves against active folders only')
28+
expect(described).not.toContain('folderPath')
29+
}
30+
)
31+
})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({
3030
recordKnowledgeBaseFileOwnership: vi.fn(),
3131
recordAudit: vi.fn(),
3232
captureServerEvent: vi.fn(),
33+
assertTagSlotsAreDefined: vi.fn(),
3334
getDocumentTagDefinitions: vi.fn(),
3435
}))
3536

@@ -78,6 +79,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
7879
}))
7980

8081
vi.mock('@/lib/knowledge/tags/service', () => ({
82+
assertTagSlotsAreDefined: mocks.assertTagSlotsAreDefined,
8183
getDocumentTagDefinitions: mocks.getDocumentTagDefinitions,
8284
}))
8385

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
resolveKnowledgeTagFilters,
6666
toKnowledgeTagFilterConditions,
6767
} from '@/lib/knowledge/tags/filter-resolution'
68-
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
68+
import { assertTagSlotsAreDefined, getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
6969
import { validateTagValue } from '@/lib/knowledge/tags/utils'
7070
import { StorageService } from '@/lib/uploads'
7171
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
@@ -901,6 +901,13 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({
901901
updates,
902902
await resolveKnowledgeDocumentTagValueUpdates(context.knowledgeBaseId, input.tagValues)
903903
)
904+
} else {
905+
/**
906+
* `tagValues` addresses definitions by id and cannot name an undefined
907+
* slot; `input.updates` carries the raw `tag1`..`tag7` the API surfaces
908+
* accept, and can.
909+
*/
910+
await assertTagSlotsAreDefined(context.knowledgeBaseId, updates)
904911
}
905912
const updatedFields = Object.keys(updates).filter(
906913
(key) => updates[key as keyof typeof updates] !== undefined
@@ -962,6 +969,12 @@ export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({
962969
return {
963970
operation: input.operation,
964971
successCount: result.successCount,
972+
/**
973+
* Only an explicit selection can name an id that matched nothing, so a
974+
* `selectAll` sweep reports an empty array rather than omitting the field:
975+
* one response shape for both selections.
976+
*/
977+
errors: result.errors,
965978
updatedDocuments: result.updatedDocuments,
966979
/**
967980
* Reported so a surface can tell a bounded selection from an unbounded
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { document } from '@sim/db/schema'
5+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/lib/knowledge/documents/document-processor', () => ({
9+
processDocument: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/knowledge/embedding-models', () => ({
13+
EMBEDDING_DIMENSIONS: 1536,
14+
getEmbeddingModelInfo: vi.fn(() => ({ tokenizerProvider: 'openai' })),
15+
}))
16+
17+
vi.mock('@/lib/knowledge/embeddings', () => ({
18+
generateEmbeddings: vi.fn(),
19+
}))
20+
21+
import { bulkDocumentOperation } from '@/lib/knowledge/documents/service'
22+
23+
const KNOWLEDGE_BASE_ID = 'knowledge-base-1'
24+
25+
describe('bulkDocumentOperation unmatched-id reporting', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
resetDbChainMock()
29+
})
30+
31+
it('reports an id that matched no updatable document without failing the request', async () => {
32+
queueTableRows(document, [{ id: 'document-1', enabled: true }])
33+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1', enabled: false }])
34+
35+
const result = await bulkDocumentOperation(
36+
KNOWLEDGE_BASE_ID,
37+
'disable',
38+
['document-1', 'document-missing'],
39+
'request-1'
40+
)
41+
42+
expect(result.successCount).toBe(1)
43+
expect(result.errors).toEqual(['No matching documents found to disable: document-missing'])
44+
expect(result.success).toBe(false)
45+
})
46+
47+
it('answers a zero-match selection the same way, rather than throwing not-found', async () => {
48+
queueTableRows(document, [])
49+
50+
const result = await bulkDocumentOperation(
51+
KNOWLEDGE_BASE_ID,
52+
'enable',
53+
['missing-1', 'missing-2'],
54+
'request-2'
55+
)
56+
57+
expect(result.successCount).toBe(0)
58+
expect(result.updatedDocuments).toEqual([])
59+
expect(result.errors).toEqual(['No matching documents found to enable: missing-1, missing-2'])
60+
expect(result.success).toBe(false)
61+
})
62+
63+
it('reports no errors when every requested document matched', async () => {
64+
queueTableRows(document, [
65+
{ id: 'document-1', enabled: false },
66+
{ id: 'document-2', enabled: false },
67+
])
68+
dbChainMockFns.returning.mockResolvedValueOnce([
69+
{ id: 'document-1', enabled: true },
70+
{ id: 'document-2', enabled: true },
71+
])
72+
73+
const result = await bulkDocumentOperation(
74+
KNOWLEDGE_BASE_ID,
75+
'enable',
76+
['document-1', 'document-2'],
77+
'request-3'
78+
)
79+
80+
expect(result.successCount).toBe(2)
81+
expect(result.errors).toEqual([])
82+
expect(result.success).toBe(true)
83+
})
84+
})

0 commit comments

Comments
 (0)