mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
fix(knowledge-fs): handle initial revisions and published evidence
This commit is contained in:
parent
afee5e28d2
commit
1648d8ac6b
@ -99,6 +99,7 @@ from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSGoldenQuestionResponse,
|
||||
KnowledgeFSIdempotencyHeader,
|
||||
KnowledgeFSJWKSResponse,
|
||||
KnowledgeFSLogicalDocumentDeletePayload,
|
||||
KnowledgeFSLogicalDocumentListResponse,
|
||||
KnowledgeFSLogicalDocumentResponse,
|
||||
KnowledgeFSMembersReplacePayload,
|
||||
@ -207,6 +208,7 @@ register_schema_models(
|
||||
KnowledgeFSBulkDocumentDeletePayload,
|
||||
KnowledgeFSDocumentChunkListQuery,
|
||||
KnowledgeFSDocumentDeletePayload,
|
||||
KnowledgeFSLogicalDocumentDeletePayload,
|
||||
KnowledgeFSDocumentMetadataPayload,
|
||||
KnowledgeFSDocumentReindexPayload,
|
||||
KnowledgeFSGoldenQuestionBulkImportPayload,
|
||||
@ -1126,7 +1128,7 @@ class KnowledgeFSSpaceLogicalDocumentApi(Resource):
|
||||
)
|
||||
return dump_response(KnowledgeFSLogicalDocumentResponse, result)
|
||||
|
||||
@console_ns.expect(console_ns.models[KnowledgeFSDocumentDeletePayload.__name__])
|
||||
@console_ns.expect(console_ns.models[KnowledgeFSLogicalDocumentDeletePayload.__name__])
|
||||
@console_ns.doc(params=_IDEMPOTENCY_HEADER_PARAMS)
|
||||
@console_ns.response(
|
||||
HTTPStatus.ACCEPTED,
|
||||
@ -1144,7 +1146,7 @@ class KnowledgeFSSpaceLogicalDocumentApi(Resource):
|
||||
account_id=actor_id,
|
||||
control_space_id=control_space_id,
|
||||
document_id=document_id,
|
||||
payload=_payload(KnowledgeFSDocumentDeletePayload),
|
||||
payload=_payload(KnowledgeFSLogicalDocumentDeletePayload),
|
||||
idempotency_key=_idempotency_key(),
|
||||
)
|
||||
return dump_response(KnowledgeFSDurableDeletionAcceptedResponse, result), HTTPStatus.ACCEPTED
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 5,
|
||||
"subtreeTree": "03fdb59383b64f9cd299b89a6e946f000502ade0",
|
||||
"openapiSha256": "18e3611208c255895cce4a5101bb0ab4466a57389e981a0e70bccacdef96974a",
|
||||
"subtreeTree": "fed7e05b3624199d02909e2e032fbf82a92c435a",
|
||||
"openapiSha256": "189c98cd2535829d75b090a70c22dac720a9e9349b8b2cde1602abc31d14f8b8",
|
||||
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
|
||||
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",
|
||||
"productOperationManifestSha256": "a2d5e2b72b87652205d505ff85ce2ca689dbd96a466495883bbf1c4b8a54ff32",
|
||||
|
||||
@ -58,6 +58,7 @@ from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSGrepResponse,
|
||||
KnowledgeFSListQuery,
|
||||
KnowledgeFSListResponse,
|
||||
KnowledgeFSLogicalDocumentDeletePayload,
|
||||
KnowledgeFSLogicalDocumentListResponse,
|
||||
KnowledgeFSLogicalDocumentResponse,
|
||||
KnowledgeFSOverviewActivityListResponse,
|
||||
@ -627,7 +628,7 @@ class KnowledgeFSDataFacade:
|
||||
account_id: str,
|
||||
control_space_id: str,
|
||||
document_id: str,
|
||||
payload: KnowledgeFSDocumentDeletePayload,
|
||||
payload: KnowledgeFSLogicalDocumentDeletePayload,
|
||||
idempotency_key: str,
|
||||
) -> KnowledgeFSDurableDeletionAcceptedResponse:
|
||||
raw = self._interactive_child(
|
||||
|
||||
@ -1150,6 +1150,12 @@ class KnowledgeFSDocumentDeletePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
|
||||
|
||||
|
||||
class KnowledgeFSLogicalDocumentDeletePayload(BaseModel):
|
||||
expected_revision: int = Field(ge=0, alias="expectedRevision")
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_by_alias=True, validate_by_name=True)
|
||||
|
||||
|
||||
class KnowledgeFSBulkDocumentDeleteItemPayload(KnowledgeFSDocumentDeletePayload):
|
||||
document_id: str = Field(min_length=1, alias="documentId")
|
||||
|
||||
@ -2450,6 +2456,7 @@ __all__ = [
|
||||
"KnowledgeFSIdempotencyHeader",
|
||||
"KnowledgeFSJWKResponse",
|
||||
"KnowledgeFSJWKSResponse",
|
||||
"KnowledgeFSLogicalDocumentDeletePayload",
|
||||
"KnowledgeFSLogicalDocumentListResponse",
|
||||
"KnowledgeFSLogicalDocumentResponse",
|
||||
"KnowledgeFSMemberBindingPayload",
|
||||
|
||||
@ -19,6 +19,7 @@ from controllers.service_api.knowledge_fs import resources as service_resources
|
||||
from services.knowledge_fs.credential_service import KnowledgeFSServiceCredentialProfile
|
||||
from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSDocumentUploadAcceptedResponse,
|
||||
KnowledgeFSDurableDeletionAcceptedResponse,
|
||||
KnowledgeFSSmallFileUploadResponse,
|
||||
KnowledgeFSSpaceCreatePayload,
|
||||
)
|
||||
@ -313,6 +314,59 @@ def test_document_upload_console_bff_reads_only_through_facade_and_returns_accep
|
||||
]
|
||||
|
||||
|
||||
def test_logical_document_delete_accepts_initial_row_version(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class Facade:
|
||||
def delete_logical_document(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
assert kwargs["payload"].expected_revision == 0
|
||||
return KnowledgeFSDurableDeletionAcceptedResponse.model_validate(
|
||||
{
|
||||
"job": {
|
||||
"checkpoint": "requested",
|
||||
"createdAt": "2030-01-01T00:00:00Z",
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"knowledgeSpaceId": "space-1",
|
||||
"runState": "queued",
|
||||
"targetId": "00000000-0000-4000-8000-000000000002",
|
||||
"targetType": "logical_document",
|
||||
"updatedAt": "2030-01-01T00:00:00Z",
|
||||
},
|
||||
"statusUrl": "/deletion-jobs/job-1",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(console_resources, "_actor", lambda: ("account-1", "tenant-1"))
|
||||
monkeypatch.setattr(console_resources, "_console_services", lambda: SimpleNamespace(facade=Facade()))
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context(
|
||||
method="DELETE",
|
||||
json={"expectedRevision": 0},
|
||||
headers={"Idempotency-Key": "delete-logical-document-once"},
|
||||
):
|
||||
delete = inspect.unwrap(console_resources.KnowledgeFSSpaceLogicalDocumentApi.delete)
|
||||
response, status = delete(
|
||||
console_resources.KnowledgeFSSpaceLogicalDocumentApi(),
|
||||
"control-1",
|
||||
"document-1",
|
||||
)
|
||||
|
||||
assert status == 202
|
||||
assert response["job"]["target_type"] == "logical_document"
|
||||
assert len(calls) == 1
|
||||
assert {name: value for name, value in calls[0].items() if name != "payload"} == {
|
||||
"tenant_id": "tenant-1",
|
||||
"account_id": "account-1",
|
||||
"control_space_id": "control-1",
|
||||
"document_id": "document-1",
|
||||
"idempotency_key": "delete-logical-document-once",
|
||||
}
|
||||
|
||||
|
||||
def test_small_file_console_bff_maps_oversize_to_413() -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
|
||||
@ -20,6 +20,7 @@ from services.knowledge_fs.product_dto import (
|
||||
KnowledgeFSGoldenQuestionPayload,
|
||||
KnowledgeFSGrepQuery,
|
||||
KnowledgeFSListQuery,
|
||||
KnowledgeFSLogicalDocumentDeletePayload,
|
||||
KnowledgeFSProductRerankProfile,
|
||||
KnowledgeFSProductRetrievalProfile,
|
||||
KnowledgeFSProductScoreThreshold,
|
||||
@ -1423,6 +1424,27 @@ def test_advanced_facade_binds_child_resources_parent_space_and_idempotency() ->
|
||||
]
|
||||
|
||||
|
||||
def test_logical_document_delete_preserves_initial_row_version() -> None:
|
||||
remote = RecordingRemote()
|
||||
facade = KnowledgeFSDataFacade(broker=RecordingBroker(), remote=remote) # type: ignore[arg-type]
|
||||
|
||||
deletion = facade.delete_logical_document(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
control_space_id="control-1",
|
||||
document_id="document-1",
|
||||
payload=KnowledgeFSLogicalDocumentDeletePayload(expected_revision=0),
|
||||
idempotency_key="delete-logical-once",
|
||||
)
|
||||
|
||||
request = remote.requests[-1]
|
||||
assert deletion.job.target_type == "logical_document"
|
||||
assert request.operation_id == "deleteLogicalDocument"
|
||||
assert request.path == "/knowledge-spaces/space-1/logical-documents/document-1"
|
||||
assert request.payload == {"expectedRevision": 0}
|
||||
assert request.headers == (("Idempotency-Key", "delete-logical-once"),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "response_name", "operation_id", "specific_kwargs", "child_resource_id"),
|
||||
[
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
# Golden-question published-generation evidence resolution
|
||||
|
||||
Date: 2026-08-02
|
||||
|
||||
## What changed
|
||||
|
||||
- Golden-question create and update validation now resolves evidence node ids across retained
|
||||
publication generations.
|
||||
- Production bad-case promotion and failed-query promotion use the same durable evidence lookup.
|
||||
- Direct document-asset evidence ids, candidate permission checks, backing-asset checks, and
|
||||
required-permission snapshots remain unchanged.
|
||||
- Added a regression proving that a node from a published generation can be selected from evidence
|
||||
search and saved as an expected evidence id.
|
||||
|
||||
## Why
|
||||
|
||||
Evidence matching reads the immutable published projection snapshot, so the returned node ids
|
||||
belong to a publication generation. Golden-question validation previously called the ordinary
|
||||
generation-scoped `getMany` repository method without a generation id. That method intentionally
|
||||
defaults to legacy rows whose `publication_generation_id` is null. A valid published candidate was
|
||||
therefore misclassified as a direct document-asset id and the save failed with `404 Expected
|
||||
evidence not found`.
|
||||
|
||||
The repository already owns `getManyByIdsAcrossGenerations` specifically for durable evidence
|
||||
references whose globally unique ids survive publication changes. Using that lookup makes the
|
||||
search and save boundaries agree without weakening authorization.
|
||||
|
||||
## Performance and reliability
|
||||
|
||||
- Node lookup remains one bounded batch for all selected evidence ids; there is no per-node query.
|
||||
- The existing maximum of 50 expected evidence ids still bounds the cross-generation lookup.
|
||||
- Backing document assets are still checked before persistence and malformed or unauthorized
|
||||
permission scopes continue to fail closed.
|
||||
- Published and retained generations are addressed only by globally unique node ids; no
|
||||
unbounded generation scan is introduced.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red phase reproduced the failure: a published-generation node was ignored by the legacy
|
||||
`getMany` path and the expected permission scope was empty.
|
||||
- Focused golden-question and failed-query handler and gateway tests passed: 4 files, 38 tests.
|
||||
- KnowledgeFS API typecheck passed.
|
||||
- `pnpm check` passed, including workspace tests, coverage gates, evaluations, contract checks,
|
||||
migration checks, Compose validation, and smoke-test definitions.
|
||||
- `pnpm build` passed for all 12 packages.
|
||||
- The four changed TypeScript source/test files pass focused Biome checks, and `git diff --check`
|
||||
passed.
|
||||
- The Dify KnowledgeFS contract lock was intentionally refreshed and its `--check` command passed.
|
||||
This fix does not add a Golden Question OpenAPI or Capability v2 contract change; the staged
|
||||
OpenAPI digest update belongs to the separate logical-document deletion fix.
|
||||
|
||||
## Known baseline
|
||||
|
||||
- Full `pnpm lint` remains blocked by 10 pre-existing findings in unchanged Admin, fixture,
|
||||
OpenAPI, and generated capability files. No finding is in a file changed by this fix.
|
||||
|
||||
## Rollout
|
||||
|
||||
- The KnowledgeFS API must be rebuilt and redeployed before retrying the Console PATCH request.
|
||||
- The production request supplied for diagnosis was not replayed because it mutates user data.
|
||||
@ -0,0 +1,66 @@
|
||||
# Logical document initial-revision deletion
|
||||
|
||||
Date: 2026-08-02
|
||||
|
||||
## What changed
|
||||
|
||||
- Split logical-document deletion from document-asset deletion at both the Dify Console BFF and
|
||||
KnowledgeFS request-contract boundaries.
|
||||
- Logical-document deletion now accepts a non-negative row version, including the initial
|
||||
`expectedRevision: 0` value returned by the logical-document API.
|
||||
- Document-asset deletion remains strictly positive; the shared asset payload was not relaxed.
|
||||
- Updated the durable deletion repository guard so compare-and-swap deletion can target an
|
||||
initial logical-document row version of zero.
|
||||
- Regenerated the Dify Console TypeScript contract and added controller, facade, handler,
|
||||
repository, and generated-contract regression coverage.
|
||||
|
||||
## Why
|
||||
|
||||
Logical documents are created with `row_version = 0`, and their response contract already exposes
|
||||
zero as a valid row version. The delete route incorrectly reused the document-asset payload, whose
|
||||
version starts at one. As a result, a correct delete request for a newly created logical document
|
||||
was rejected first by Dify's Pydantic validation and, if it reached KnowledgeFS, by the shared Zod
|
||||
and repository positive-integer guards.
|
||||
|
||||
This change gives the two independently versioned resources separate payload contracts and keeps
|
||||
optimistic concurrency intact. It does not bypass or remove the expected-revision check.
|
||||
|
||||
## Performance and reliability
|
||||
|
||||
- The deletion path, transaction count, SQL compare-and-swap predicate, and durable job behavior
|
||||
are unchanged.
|
||||
- Validation remains constant time and no additional remote calls or database queries were added.
|
||||
- A stale zero revision still conflicts after the logical document advances to a later row version.
|
||||
- The idempotency-key requirement remains unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red phase reproduced rejection at all affected boundaries:
|
||||
- KnowledgeFS route validation rejected logical `expectedRevision: 0`.
|
||||
- PostgreSQL and TiDB repository tests rejected a zero logical row version.
|
||||
- Dify Console payload validation rejected a zero logical row version.
|
||||
- The generated Console client contract rejected a zero logical row version.
|
||||
- Focused KnowledgeFS handler and repository tests passed: 2 files, 85 tests.
|
||||
- KnowledgeFS API typecheck passed.
|
||||
- `pnpm check` and `pnpm build` passed.
|
||||
- Dify controller and data-facade tests passed: 104 tests.
|
||||
- Targeted Dify Ruff, Pyrefly, and Mypy checks passed.
|
||||
- Generated Console logical-document deletion contract smoke test passed.
|
||||
- Generated Console contract typecheck passed.
|
||||
- Contract generation and lock checks passed.
|
||||
- Targeted Python Ruff and KnowledgeFS Biome checks passed.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## Known risks / follow-up
|
||||
|
||||
- The Dify API and KnowledgeFS API must both be deployed before retrying the production request;
|
||||
deploying only one side leaves another rejecting boundary in place.
|
||||
- Full `pnpm lint` remains blocked by 10 pre-existing findings in unchanged Admin, fixture,
|
||||
OpenAPI, and generated capability files. All changed KnowledgeFS TypeScript files pass focused
|
||||
Biome checks.
|
||||
- The standalone KnowledgeFS API coverage command executes all tests successfully but the existing
|
||||
repository-wide branch result is 89.94%, below its 90% threshold. This change adds no runtime
|
||||
branches, and the changed request-schema file reports 100% coverage.
|
||||
- The production request supplied for diagnosis was not replayed because it was destructive.
|
||||
- Temporary progress documents were not recreated; this change record is the traceability source
|
||||
for this fix.
|
||||
@ -85,7 +85,7 @@ describe("durable deletion handlers", () => {
|
||||
const response = await app.request(
|
||||
`/knowledge-spaces/${SPACE_ID}/logical-documents/${DOCUMENT_ID}`,
|
||||
{
|
||||
body: JSON.stringify({ expectedRevision: 4 }),
|
||||
body: JSON.stringify({ expectedRevision: 0 }),
|
||||
headers: requestHeaders(),
|
||||
method: "DELETE",
|
||||
},
|
||||
@ -101,7 +101,7 @@ describe("durable deletion handlers", () => {
|
||||
expect.objectContaining({
|
||||
callerKind: "interactive",
|
||||
documentId: DOCUMENT_ID,
|
||||
expectedRevision: 4,
|
||||
expectedRevision: 0,
|
||||
idempotencyKey: "delete-space-0001",
|
||||
knowledgeSpaceId: SPACE_ID,
|
||||
}),
|
||||
@ -109,6 +109,19 @@ describe("durable deletion handlers", () => {
|
||||
expect(service.requestDocumentDeletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps document-asset deletion versions strictly positive", async () => {
|
||||
const service = serviceStub();
|
||||
const app = testApp(service);
|
||||
const response = await app.request(`/knowledge-spaces/${SPACE_ID}/documents/${DOCUMENT_ID}`, {
|
||||
body: JSON.stringify({ expectedRevision: 0 }),
|
||||
headers: requestHeaders(),
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(service.requestDocumentDeletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed with 503 when the durable service is not configured", async () => {
|
||||
const app = testApp(undefined);
|
||||
const response = await app.request(
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
DeleteDocumentParams,
|
||||
DeleteKnowledgeSpaceBody,
|
||||
DeleteKnowledgeSpaceParams,
|
||||
DeleteLogicalDocumentBody,
|
||||
DeleteSourceBody,
|
||||
DeleteSourceParams,
|
||||
DeleteSourceQuery,
|
||||
@ -176,7 +177,7 @@ export function registerDurableDeletionHandlers({
|
||||
return unavailable(context);
|
||||
}
|
||||
const params = context.req.valid("param") as DeleteDocumentParams;
|
||||
const body = context.req.valid("json") as DeleteDocumentBody;
|
||||
const body = context.req.valid("json") as DeleteLogicalDocumentBody;
|
||||
const headers = context.req.valid("header") as DurableDeletionIdempotencyHeaders;
|
||||
try {
|
||||
const accepted = await service.requestLogicalDocumentDeletion({
|
||||
|
||||
@ -556,7 +556,7 @@ describe.each(["postgres", "tidb"] as const)(
|
||||
? [
|
||||
jobRow({
|
||||
idempotency_key: "delete-logical-a",
|
||||
target_revision: 4,
|
||||
target_revision: 0,
|
||||
target_type: "logical_document",
|
||||
}),
|
||||
]
|
||||
@ -582,7 +582,7 @@ describe.each(["postgres", "tidb"] as const)(
|
||||
rows: [
|
||||
{
|
||||
active_revision: null,
|
||||
row_version: 4,
|
||||
row_version: 0,
|
||||
source_id: null,
|
||||
status: "failed",
|
||||
},
|
||||
@ -638,7 +638,7 @@ describe.each(["postgres", "tidb"] as const)(
|
||||
accessChannel: "interactive",
|
||||
createdAt,
|
||||
documentId: targetId,
|
||||
expectedDocumentRowVersion: 4,
|
||||
expectedDocumentRowVersion: 0,
|
||||
idempotencyKey: "delete-logical-a",
|
||||
knowledgeSpaceId,
|
||||
permissionSnapshotId,
|
||||
@ -648,7 +648,7 @@ describe.each(["postgres", "tidb"] as const)(
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
created: true,
|
||||
job: { targetRevision: 4, targetType: "logical_document" },
|
||||
job: { targetRevision: 0, targetType: "logical_document" },
|
||||
});
|
||||
|
||||
const logicalUpdate = calls.find(
|
||||
@ -665,7 +665,7 @@ describe.each(["postgres", "tidb"] as const)(
|
||||
tenantId,
|
||||
knowledgeSpaceId,
|
||||
targetId,
|
||||
4,
|
||||
0,
|
||||
]);
|
||||
expect(assetUpdate?.inTransaction).toBe(true);
|
||||
expect(assetUpdate?.sql).toContain("owned_revision");
|
||||
|
||||
@ -609,7 +609,7 @@ export function createDatabaseDurableDeletionRepository({
|
||||
: target.type === "logical_document"
|
||||
? {
|
||||
deleteMode: "cascade" as const,
|
||||
expectedRevision: positiveInteger(
|
||||
expectedRevision: nonnegativeInteger(
|
||||
(input as RequestLogicalDocumentDeletionInput).expectedDocumentRowVersion,
|
||||
"expectedDocumentRowVersion",
|
||||
),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
const ExpectedRevisionSchema = z.number().int().positive();
|
||||
const LogicalDocumentExpectedRevisionSchema = z.number().int().nonnegative();
|
||||
|
||||
export const DurableDeletionIdempotencyHeadersSchema = z
|
||||
.object({
|
||||
@ -51,6 +52,12 @@ export const DeleteDocumentBodySchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DeleteLogicalDocumentBodySchema = z
|
||||
.object({
|
||||
expectedRevision: LogicalDocumentExpectedRevisionSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const BulkDeleteDocumentsBodySchema = z
|
||||
.object({
|
||||
documents: z
|
||||
@ -72,6 +79,7 @@ export type DeleteSourceBody = z.infer<typeof DeleteSourceBodySchema>;
|
||||
export type DeleteSourceParams = z.infer<typeof DeleteSourceParamsSchema>;
|
||||
export type DeleteSourceQuery = z.infer<typeof DurableDeleteSourceQuerySchema>;
|
||||
export type DeleteDocumentBody = z.infer<typeof DeleteDocumentBodySchema>;
|
||||
export type DeleteLogicalDocumentBody = z.infer<typeof DeleteLogicalDocumentBodySchema>;
|
||||
export type DeleteDocumentParams = z.infer<typeof DeleteDocumentParamsSchema>;
|
||||
export type BulkDeleteDocumentsBody = z.infer<typeof BulkDeleteDocumentsBodySchema>;
|
||||
export type DurableDeletionIdempotencyHeaders = z.infer<
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
DeleteDocumentParamsSchema,
|
||||
DeleteKnowledgeSpaceBodySchema,
|
||||
DeleteKnowledgeSpaceParamsSchema,
|
||||
DeleteLogicalDocumentBodySchema,
|
||||
DeleteSourceBodySchema,
|
||||
DeleteSourceParamsSchema,
|
||||
DurableDeleteSourceQuerySchema,
|
||||
@ -129,7 +130,7 @@ export const requestLogicalDocumentDeletionRoute = createRoute({
|
||||
path: "/knowledge-spaces/{id}/logical-documents/{documentId}",
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: DeleteDocumentBodySchema } },
|
||||
content: { "application/json": { schema: DeleteLogicalDocumentBodySchema } },
|
||||
required: true,
|
||||
},
|
||||
headers: DurableDeletionIdempotencyHeadersSchema,
|
||||
|
||||
@ -284,7 +284,7 @@ function failedQueryFixture(options: FailedQueryFixtureOptions = {}) {
|
||||
...(options.triageRunner === null
|
||||
? {}
|
||||
: { failedQueryTriageRunner: { run: triageRun } as never }),
|
||||
nodes: { getMany: vi.fn(async () => []) } as never,
|
||||
nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => []) } as never,
|
||||
now: () => "2026-07-14T12:00:00.000Z",
|
||||
spaces: {
|
||||
get: vi.fn(async () => (options.space === undefined ? { id: SPACE_ID } : options.space)),
|
||||
|
||||
@ -34,7 +34,7 @@ export interface RegisterFailedQueryHandlersOptions {
|
||||
readonly failedQueries: FailedQueryRepository;
|
||||
readonly failedQueryTriageRunner?: FailedQueryTriageRunner | undefined;
|
||||
readonly now?: () => string;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getMany">;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getManyByIdsAcrossGenerations">;
|
||||
readonly spaces: KnowledgeSpaceRepository;
|
||||
}
|
||||
|
||||
|
||||
@ -231,6 +231,33 @@ describe("golden-question handler branch coverage", () => {
|
||||
});
|
||||
|
||||
describe("golden-question evidence permission scope branches", () => {
|
||||
it("resolves published-generation evidence nodes by their durable ids", async () => {
|
||||
const getMany = vi.fn(async () => []);
|
||||
const getManyByIdsAcrossGenerations = vi.fn(async () => [
|
||||
{
|
||||
documentAssetId: ASSET_ID,
|
||||
id: NODE_ID,
|
||||
permissionScope: ["team:a"],
|
||||
publicationGenerationId: "018f0d60-7a49-7cc2-9c1b-5b36f18f2c46",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
goldenQuestionEvidencePermissionScope({
|
||||
assets: { get: vi.fn(async () => ({ metadata: {} })) } as never,
|
||||
candidateGrants: ["team:a"],
|
||||
expectedEvidenceIds: [NODE_ID],
|
||||
knowledgeSpaceId: SPACE_ID,
|
||||
nodes: { getMany, getManyByIdsAcrossGenerations } as never,
|
||||
}),
|
||||
).resolves.toEqual(["team:a"]);
|
||||
expect(getManyByIdsAcrossGenerations).toHaveBeenCalledWith({
|
||||
ids: [NODE_ID],
|
||||
knowledgeSpaceId: SPACE_ID,
|
||||
});
|
||||
expect(getMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty, duplicate, direct-asset, and node-backed evidence", async () => {
|
||||
await expect(evidenceScope({ expectedEvidenceIds: [] })).resolves.toEqual([]);
|
||||
await expect(evidenceScope({ expectedEvidenceIds: [ASSET_ID, ASSET_ID] })).resolves.toBeNull();
|
||||
@ -353,7 +380,7 @@ function goldenFixture(options: GoldenFixtureOptions = {}) {
|
||||
get: vi.fn(async () => (options.asset === undefined ? { metadata: {} } : options.asset)),
|
||||
} as never,
|
||||
authorization: { authorize } as never,
|
||||
nodes: { getMany: vi.fn(async () => []) } as never,
|
||||
nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => []) } as never,
|
||||
now: () => "2026-07-14T12:00:00.000Z",
|
||||
questions: questions as never,
|
||||
spaces: {
|
||||
@ -414,7 +441,7 @@ function evidenceScope(options: {
|
||||
candidateGrants: options.candidateGrants ?? [],
|
||||
expectedEvidenceIds: options.expectedEvidenceIds,
|
||||
knowledgeSpaceId: SPACE_ID,
|
||||
nodes: { getMany: vi.fn(async () => options.nodes ?? []) } as never,
|
||||
nodes: { getManyByIdsAcrossGenerations: vi.fn(async () => options.nodes ?? []) } as never,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ export interface RegisterGoldenQuestionHandlersOptions {
|
||||
readonly assets: Pick<DocumentAssetRepository, "get">;
|
||||
readonly authorization: KnowledgeSpaceAuthorizationGuard;
|
||||
readonly evidenceMatcher?: GoldenQuestionEvidenceMatcher | undefined;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getMany">;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getManyByIdsAcrossGenerations">;
|
||||
readonly now: () => string;
|
||||
readonly questions: GoldenQuestionRepository;
|
||||
readonly spaces: KnowledgeSpaceRepository;
|
||||
@ -726,7 +726,7 @@ async function productionBadCaseEvidencePermissionScope(input: {
|
||||
readonly assets: Pick<DocumentAssetRepository, "get">;
|
||||
readonly bundle: EvidenceBundle | null;
|
||||
readonly knowledgeSpaceId: string;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getMany">;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getManyByIdsAcrossGenerations">;
|
||||
readonly permissionScopes: readonly string[];
|
||||
}): Promise<readonly string[] | null> {
|
||||
if (!input.bundle) {
|
||||
@ -746,7 +746,7 @@ async function productionBadCaseEvidencePermissionScope(input: {
|
||||
.map((missing) => missing.expectedEvidenceId)
|
||||
.filter((id): id is string => id !== undefined),
|
||||
);
|
||||
const referencedNodes = await input.nodes.getMany({
|
||||
const referencedNodes = await input.nodes.getManyByIdsAcrossGenerations({
|
||||
ids: uniqueStrings([...requiredNodeIds, ...optionalMissingNodeIds]),
|
||||
knowledgeSpaceId: input.knowledgeSpaceId,
|
||||
});
|
||||
@ -804,12 +804,12 @@ export async function goldenQuestionEvidencePermissionScope(input: {
|
||||
readonly candidateGrants: readonly string[];
|
||||
readonly expectedEvidenceIds: readonly string[];
|
||||
readonly knowledgeSpaceId: string;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getMany">;
|
||||
readonly nodes: Pick<KnowledgeNodeRepository, "getManyByIdsAcrossGenerations">;
|
||||
}): Promise<readonly string[] | null> {
|
||||
const evidenceIds = uniqueStrings(input.expectedEvidenceIds);
|
||||
if (evidenceIds.length !== input.expectedEvidenceIds.length) return null;
|
||||
if (evidenceIds.length === 0) return [];
|
||||
const nodes = await input.nodes.getMany({
|
||||
const nodes = await input.nodes.getManyByIdsAcrossGenerations({
|
||||
ids: evidenceIds,
|
||||
knowledgeSpaceId: input.knowledgeSpaceId,
|
||||
});
|
||||
|
||||
@ -378,6 +378,10 @@ export type KnowledgeFsLogicalDocumentListResponse = {
|
||||
next_cursor?: string | null
|
||||
}
|
||||
|
||||
export type KnowledgeFsLogicalDocumentDeletePayload = {
|
||||
expectedRevision: number
|
||||
}
|
||||
|
||||
export type KnowledgeFsMembersReplacePayload = {
|
||||
members: Array<KnowledgeFsMemberBindingPayload>
|
||||
}
|
||||
@ -2255,7 +2259,7 @@ export type GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse =
|
||||
GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses[keyof GetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponses]
|
||||
|
||||
export type DeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdData = {
|
||||
body: KnowledgeFsDocumentDeletePayload
|
||||
body: KnowledgeFsLogicalDocumentDeletePayload
|
||||
headers: {
|
||||
'Idempotency-Key': string
|
||||
}
|
||||
|
||||
@ -269,6 +269,13 @@ export const zKnowledgeFsDocumentCompilationJobResponse = z.object({
|
||||
version: z.int().gte(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSLogicalDocumentDeletePayload
|
||||
*/
|
||||
export const zKnowledgeFsLogicalDocumentDeletePayload = z.object({
|
||||
expectedRevision: z.int().gte(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* KnowledgeFSBadCaseCreatePayload
|
||||
*/
|
||||
@ -2577,7 +2584,7 @@ export const zGetKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsResponse =
|
||||
zKnowledgeFsLogicalDocumentListResponse
|
||||
|
||||
export const zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody =
|
||||
zKnowledgeFsDocumentDeletePayload
|
||||
zKnowledgeFsLogicalDocumentDeletePayload
|
||||
|
||||
export const zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders = z.object(
|
||||
{
|
||||
|
||||
@ -3,6 +3,7 @@ import { logicalDocuments } from './generated/api/console/knowledge-fs/orpc.gen'
|
||||
import {
|
||||
zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody,
|
||||
zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders,
|
||||
zKnowledgeFsDocumentDeletePayload,
|
||||
} from './generated/api/console/knowledge-fs/zod.gen'
|
||||
|
||||
describe('generated KnowledgeFS logical document deletion contract', () => {
|
||||
@ -17,6 +18,11 @@ describe('generated KnowledgeFS logical document deletion contract', () => {
|
||||
zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdBody.safeParse({
|
||||
expectedRevision: 0,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
zKnowledgeFsDocumentDeletePayload.safeParse({
|
||||
expectedRevision: 0,
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
zDeleteKnowledgeFsSpacesByControlSpaceIdLogicalDocumentsByDocumentIdHeaders.safeParse({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user