From 5344592c7821b314f58d789b0ff299654111e519 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 9 Jun 2026 14:21:44 +0200 Subject: [PATCH 01/12] update: prep for cds10 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c132c7d8d..7960a613b 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "husky": "^9.1.7" }, "peerDependencies": { - "@sap/cds": ">=8" + "@sap/cds": ">=9" }, "engines": { "node": ">=18.0.0" @@ -94,7 +94,7 @@ } }, "attachments": { - "outbox": true, + "queue": true, "scan": true, "deduplicateFileNames": true, "scanExpiryMs": 259200000, From 03878916078391138a1f3fd49aaa53954669fed5 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 16 Jun 2026 17:15:59 +0200 Subject: [PATCH 02/12] fix: gcp and test names --- srv/attachments/gcp.js | 13 +----- .../integration/attachments-non-draft.test.js | 42 +++++++++---------- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/srv/attachments/gcp.js b/srv/attachments/gcp.js index caa42459a..e349e68c3 100644 --- a/srv/attachments/gcp.js +++ b/srv/attachments/gcp.js @@ -392,17 +392,8 @@ module.exports = class GoogleAttachmentsService extends ( ) const file = bucket.file(blobName) - let response - try { - response = await file.delete() - } catch (error) { - if (error.statusCode === 404) { - response = error - } else { - throw error - } - } - if (response?.[0]?.statusCode !== 204) { + const [response] = await file.delete({ ignoreNotFound: true }) + if (response?.statusCode !== 204) { LOG.warn("File has not been deleted from Google Cloud Storage", { blobName, bucketName: bucket.name, diff --git a/tests/integration/attachments-non-draft.test.js b/tests/integration/attachments-non-draft.test.js index 0d8459353..b94bb771a 100644 --- a/tests/integration/attachments-non-draft.test.js +++ b/tests/integration/attachments-non-draft.test.js @@ -39,20 +39,20 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(resCreate.data.title).toBe("New Incident") }) - it("should create attachment metadata", async () => { + it("Should create attachment metadata", async () => { const incidentID = await newIncident(POST, "admin") const attachmentID = await createAttachmentMetadata(incidentID) expect(attachmentID).toBeDefined() }) - it("should upload attachment content", async () => { + it("Should upload attachment content", async () => { const incidentID = await newIncident(POST, "admin") const attachmentID = await createAttachmentMetadata(incidentID) const response = await uploadAttachmentContent(incidentID, attachmentID) expect(response.status).toBe(204) }) - it("unknown extension throws warning", async () => { + it("Unknown extension throws warning", async () => { const incidentID = await newIncident(POST, "admin") const response = await POST( `/odata/v4/admin/Incidents(${incidentID})/attachments`, @@ -66,7 +66,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call ) }) - it("should list attachments for incident", async () => { + it("Should list attachments for incident", async () => { const incidentID = await newIncident(POST, "admin") const attachmentID = await createAttachmentMetadata(incidentID) const scanCleanWaiter = waitForScanStatus("Clean", attachmentID) @@ -110,7 +110,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(Buffer.compare(response.data, originalContent)).toBe(0) }) - it("should delete attachment and verify deletion", async () => { + it("Should delete attachment and verify deletion", async () => { const incidentID = await newIncident(POST, "admin") const attachmentID = await createAttachmentMetadata(incidentID) const scanCleanWaiter = waitForScanStatus("Clean", attachmentID) @@ -199,7 +199,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(responseContent.status).toBe(200) }) - it("should NOT allow overwriting an existing attachment file via /content handler", async () => { + it("Should NOT allow overwriting an existing attachment file via /content handler", async () => { const incidentID = await newIncident(POST, "admin") // Create attachment metadata const attachmentID = await createAttachmentMetadata(incidentID) @@ -236,7 +236,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call ) }) - it("should ALLOW overwriting content when @Capabilities.UpdateRestrictions.NonUpdateableProperties is empty", async () => { + it("Should ALLOW overwriting content when @Capabilities.UpdateRestrictions.NonUpdateableProperties is empty", async () => { const incidentID = await newIncident(POST, "admin") // Create attachment metadata on overwritableAttachments @@ -281,7 +281,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(overwriteRes.status).toBe(204) }) - it("should add and fetch attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => { + it("Should add and fetch attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => { const testID = cds.utils.uuid() const detailsID = cds.utils.uuid() await POST(`odata/v4/processor/NonDraftTest`, { @@ -331,7 +331,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(childAttachment.data.filename).toBe("childfile.pdf") }) - it("should delete attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => { + it("Should delete attachments for both NonDraftTest and SingleTestDetails in non-draft mode", async () => { const testID = cds.utils.uuid() const detailsID = cds.utils.uuid() await POST(`odata/v4/processor/NonDraftTest`, { @@ -393,7 +393,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call }) // prettier-ignore - isNotLocal("should delete file from object store if data is deleted", async () => { + isNotLocal("Should delete file from object store if data is deleted", async () => { const detailsID = cds.utils.uuid() const testID = await newIncident( @@ -443,7 +443,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(await deletion).toBe(true) }) - it("should create NonDraftTest entities using programmatic INSERT and add attachments", async () => { + it("Should create NonDraftTest entities using programmatic INSERT and add attachments", async () => { const firstID = cds.utils.uuid() const secondID = cds.utils.uuid() @@ -498,7 +498,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call expect(attachment2.data.filename).toBe("file2.pdf") }) - it("should delete attachments for both NonDraftTest and SingleTestDetails when entities are deleted in non-draft mode", async () => { + it("Should delete attachments for both NonDraftTest and SingleTestDetails when entities are deleted in non-draft mode", async () => { const testID = cds.utils.uuid() const detailsID = cds.utils.uuid() await POST(`odata/v4/processor/NonDraftTest`, { @@ -553,7 +553,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call }) }) - it("should handle duplicate filenames on deep insert", async () => { + it("Should handle duplicate filenames on deep insert", async () => { const incidentID = cds.utils.uuid() const { data: incident } = await POST("/odata/v4/admin/Incidents", { ID: incidentID, @@ -838,7 +838,7 @@ describe("Testing max and min amounts of attachments", () => { }) }) - it("custom error message can be specified targeting composition property", async () => { + it("Custom error message can be specified targeting composition property", async () => { await POST(`odata/v4/validation-test-non-draft/Incidents`, { customer_ID: "1004155", title: "ABC", @@ -860,7 +860,7 @@ describe("Testing max and min amounts of attachments", () => { }) }) - it("custom error message can be specified for entity", async () => { + it("Custom error message can be specified for entity", async () => { await POST(`odata/v4/validation-test-non-draft/Incidents`, { customer_ID: "1004155", title: "ABC", @@ -924,7 +924,7 @@ describe("Row-level security on attachments composition", () => { await scanCleanWaiter }) - it("should allow DOWNLOAD attachment content for authorized user (alice)", async () => { + it("Should allow DOWNLOAD attachment content for authorized user (alice)", async () => { // Now, try to GET the attachment content as alice const getRes = await GET( `/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})/content`, @@ -936,7 +936,7 @@ describe("Row-level security on attachments composition", () => { expect(getRes.data).not.toBeUndefined() }) - it("should reject CREATE attachment for unauthorized user", async () => { + it("Should reject CREATE attachment for unauthorized user", async () => { await POST( `/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments`, { @@ -950,7 +950,7 @@ describe("Row-level security on attachments composition", () => { }) }) - it("should reject UPDATE attachment for unauthorized user", async () => { + it("Should reject UPDATE attachment for unauthorized user", async () => { // Assume an attachment exists, try to update as bob await axios .patch( @@ -965,7 +965,7 @@ describe("Row-level security on attachments composition", () => { }) }) - it("should reject DOWNLOAD attachment content for unauthorized user", async () => { + it("Should reject DOWNLOAD attachment content for unauthorized user", async () => { await GET( `/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})/content`, { @@ -976,7 +976,7 @@ describe("Row-level security on attachments composition", () => { }) }) - it("should reject DELETE attachment for unauthorized user", async () => { + it("Should reject DELETE attachment for unauthorized user", async () => { await DELETE( `/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments(up__ID=${restrictionID},ID=${attachmentID})`, { @@ -987,7 +987,7 @@ describe("Row-level security on attachments composition", () => { }) }) - it("should not allow bob to PUT into file alice has POSTed", async () => { + it("Should not allow bob to PUT into file alice has POSTed", async () => { const attachRes = await POST( `/odata/v4/restriction/Incidents(ID=${restrictionID})/attachments`, { From 565cdcf530fc1c963e3797beb352d2ced652497e Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 17 Jun 2026 11:24:57 +0200 Subject: [PATCH 03/12] fix: remove lingering cds 8 features --- CHANGELOG.md | 6 ++++++ lib/generic-handlers.js | 9 +-------- lib/helper.js | 28 ---------------------------- lib/plugin.js | 12 ++++++------ package.json | 2 +- 5 files changed, 14 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ffd8723..684dc8bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). +## Version 4.0.0 + +### Changed + +- **Breaking:** The `outbox` configuration key under `cds.requires.attachments` has been renamed to `queue`. Projects that explicitly set `attachments.outbox: true` in their own CDS configuration must rename the key to `queue`. A deprecation warning is logged at startup when the old key is detected. + ## Version 3.12.2 ### Fixed diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js index 1860f41d9..f6a1b4e41 100644 --- a/lib/generic-handlers.js +++ b/lib/generic-handlers.js @@ -5,7 +5,6 @@ const { MAX_FILE_SIZE, sizeInBytes, checkMimeTypeMatch, - inferTargetCAP8, } = require("./helper") const { getMime } = require("./mime") @@ -27,13 +26,7 @@ async function finalizePrepareAttachment(data, req) { // Only try to populate parent keys if there is a parent reference if (parentRef && parentRef.length > 0) { let target - if (cds.infer?.target) { - // CAP 9+: Use cds.infer.target - target = cds.infer.target({ SELECT: { from: { ref: parentRef } } }) - } else { - // CAP 8 fallback: Use inferTargetCAP8 helper - target = inferTargetCAP8(req, parentRef) - } + target = cds.infer.target({ SELECT: { from: { ref: parentRef } } }) if (target?.keys) { LOG.info(`Populating parent keys for attachment upload`, target) diff --git a/lib/helper.js b/lib/helper.js index 4a0fc37e3..81689d320 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -428,33 +428,6 @@ async function computeHash(input) { return hash.digest("hex") } -function inferTargetCAP8(req, ref) { - const model = cds.context?.model || cds.model - - // Extract entity/navigation names from ref array - // Handle both simple strings and objects with .id property - const names = ref - .map((part) => { - if (typeof part === "string") return part - if (typeof part === "object" && part.id) return part.id - return null - }) - .filter(Boolean) - - const name = names.join(".") - - let target = model.definitions[name] - if (!target) return null - - // draft fallback - if (target["@odata.draft.enabled"]) { - const draft = model.definitions[`${name}.drafts`] - if (draft) target = draft - } - - return target -} - const multipliers = {} multipliers.B = 1 multipliers.KB = multipliers.B * 1024 @@ -838,7 +811,6 @@ module.exports = { traverseEntity, buildBackAssocChain, MAX_FILE_SIZE, - inferTargetCAP8, getAttachmentKind, handleDuplicates, createSizeCheckHandler, diff --git a/lib/plugin.js b/lib/plugin.js index 671ae43b9..5f5b3555f 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -8,14 +8,13 @@ const { validateAndInsertAttachmentFromDBHandler, } = require("./generic-handlers") const { - inferTargetCAP8, getAttachmentKind, handleDuplicates, } = require("./helper") require("./csn-runtime-extension") const LOG = cds.log("attachments") -cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel) +cds.on("compile.to.edmx", unfoldModel) // Register the db handler ONCE (not per-service) to intercept attachment INSERT // and handle it through the attachments service instead of native DB insert @@ -24,6 +23,9 @@ cds.on(cds.version >= "8.6.0" ? "compile.to.edmx" : "loaded", unfoldModel) // NOTE: Must use db.prepend() to ensure handlers run before existing ones cds.once("served", () => { if (!cds.env.requires.attachments) return + if (cds.env.requires.attachments?.outbox !== undefined) { + LOG.warn('`cds.requires.attachments.outbox` is deprecated; use `queue` instead.') + } const { db } = cds.services db.prepend(() => { @@ -492,8 +494,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () { if (req.query?.INSERT?.into.ref.length > 1) { const ref = req.query.INSERT.into.ref.slice(0, -1) const parentQuery = { SELECT: { from: { ref: ref }, one: true } } - const parent = - cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref) + const parent = cds.infer.target(parentQuery) if (!parent) { LOG.warn( `Could not determine parent target. Ref: ${JSON.stringify(ref)}`, @@ -574,8 +575,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () { if (req.query?.DELETE?.from?.ref.length > 1) { const ref = req.query.DELETE.from.ref.slice(0, -1) const parentQuery = { SELECT: { from: { ref: ref }, one: true } } - const parent = - cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref) + const parent = cds.infer.target(parentQuery) if (!parent) { LOG.warn( `Could not determine parent target. Ref: ${JSON.stringify(ref)}`, diff --git a/package.json b/package.json index 7960a613b..fc486929b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@cap-js/attachments", "description": "CAP cds-plugin providing image and attachment storing out-of-the-box.", - "version": "3.12.2", + "version": "4.0.0", "repository": "cap-js/attachments", "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", From f6e1338b344853e01d453c9e6c10af54cbcb1f42 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 17 Jun 2026 11:28:18 +0200 Subject: [PATCH 04/12] prettier --- lib/generic-handlers.js | 6 +----- lib/plugin.js | 9 ++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js index f6a1b4e41..dde9991f3 100644 --- a/lib/generic-handlers.js +++ b/lib/generic-handlers.js @@ -1,11 +1,7 @@ const cds = require("@sap/cds") const LOG = cds.log("attachments") const { extname } = require("path") -const { - MAX_FILE_SIZE, - sizeInBytes, - checkMimeTypeMatch, -} = require("./helper") +const { MAX_FILE_SIZE, sizeInBytes, checkMimeTypeMatch } = require("./helper") const { getMime } = require("./mime") /** diff --git a/lib/plugin.js b/lib/plugin.js index 5f5b3555f..9e3ea70a1 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -7,10 +7,7 @@ const { validateAttachmentMimeType, validateAndInsertAttachmentFromDBHandler, } = require("./generic-handlers") -const { - getAttachmentKind, - handleDuplicates, -} = require("./helper") +const { getAttachmentKind, handleDuplicates } = require("./helper") require("./csn-runtime-extension") const LOG = cds.log("attachments") @@ -24,7 +21,9 @@ cds.on("compile.to.edmx", unfoldModel) cds.once("served", () => { if (!cds.env.requires.attachments) return if (cds.env.requires.attachments?.outbox !== undefined) { - LOG.warn('`cds.requires.attachments.outbox` is deprecated; use `queue` instead.') + LOG.warn( + "`cds.requires.attachments.outbox` is deprecated; use `queue` instead.", + ) } const { db } = cds.services From 9ffb71f6f8a2a3f85302a5f11bd2aa7e1ce92b7b Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 1 Jul 2026 09:54:49 +0200 Subject: [PATCH 05/12] switch from queue to outboxed --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc486929b..65d4495e4 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ } }, "attachments": { - "queue": true, + "outboxed": true, "scan": true, "deduplicateFileNames": true, "scanExpiryMs": 259200000, From 8693db81c7f52ba4a87998517829b2a310518051 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 14 Jul 2026 17:24:28 +0200 Subject: [PATCH 06/12] fix: this.results error --- lib/generic-handlers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js index 865d7f046..b07714183 100644 --- a/lib/generic-handlers.js +++ b/lib/generic-handlers.js @@ -650,6 +650,7 @@ async function validateAndInsertAttachmentFromDBHandler(data, target, req) { const attachmentService = await cds.connect.to("attachments") await attachmentService.put(attachmentTarget, attachmentData) + delete data[`${prefix}_content`] } } } From 55d3ee02b500b0713a4eb0ff8e18532dc556b43c Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 10:52:42 +0200 Subject: [PATCH 07/12] revert --- lib/generic-handlers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/generic-handlers.js b/lib/generic-handlers.js index b07714183..865d7f046 100644 --- a/lib/generic-handlers.js +++ b/lib/generic-handlers.js @@ -650,7 +650,6 @@ async function validateAndInsertAttachmentFromDBHandler(data, target, req) { const attachmentService = await cds.connect.to("attachments") await attachmentService.put(attachmentTarget, attachmentData) - delete data[`${prefix}_content`] } } } From 257f0da15f2c22624169fe8a40a2e33c891e4fdf Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 15:04:03 +0200 Subject: [PATCH 08/12] test: queue vs outboxed --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65d4495e4..fc486929b 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ } }, "attachments": { - "outboxed": true, + "queue": true, "scan": true, "deduplicateFileNames": true, "scanExpiryMs": 259200000, From c250feda5824224b0451a9e95e2cdbc8abfa8587 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 15:13:16 +0200 Subject: [PATCH 09/12] revert --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc486929b..65d4495e4 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ } }, "attachments": { - "queue": true, + "outboxed": true, "scan": true, "deduplicateFileNames": true, "scanExpiryMs": 259200000, From 0d24c0dfe07c7314278d679412308daa18f95cdc Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 15:47:31 +0200 Subject: [PATCH 10/12] test: companion hana verion --- tests/incidents-app/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/incidents-app/package.json b/tests/incidents-app/package.json index cf715cfbc..5cd4477d4 100644 --- a/tests/incidents-app/package.json +++ b/tests/incidents-app/package.json @@ -4,8 +4,9 @@ "dependencies": { "@cap-js/attachments": "file:../../.", "@cap-js/audit-logging": "^1.2.0", - "@cap-js/hana": "2.6.0", - "@cap-js/postgres": "^2.2.0" + "@cap-js/hana": ">=2.6.0", + "@cap-js/postgres": "^2.2.0", + "@sap/cds": ">=9" }, "devDependencies": { "@cap-js/sqlite": "*" From 979754576a07893dbb45d5c899dd1a236c0c9b11 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 15:56:29 +0200 Subject: [PATCH 11/12] test: hard-coded latest hana --- tests/incidents-app/package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/incidents-app/package.json b/tests/incidents-app/package.json index 5cd4477d4..d6913fa5f 100644 --- a/tests/incidents-app/package.json +++ b/tests/incidents-app/package.json @@ -4,9 +4,8 @@ "dependencies": { "@cap-js/attachments": "file:../../.", "@cap-js/audit-logging": "^1.2.0", - "@cap-js/hana": ">=2.6.0", - "@cap-js/postgres": "^2.2.0", - "@sap/cds": ">=9" + "@cap-js/hana": "3.0.1", + "@cap-js/postgres": "^2.2.0" }, "devDependencies": { "@cap-js/sqlite": "*" From ce0c09212395488034bda0a0574c31d654e15786 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 15 Jul 2026 16:34:29 +0200 Subject: [PATCH 12/12] adjust: hana versioning --- tests/incidents-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/incidents-app/package.json b/tests/incidents-app/package.json index d6913fa5f..e668d4e3e 100644 --- a/tests/incidents-app/package.json +++ b/tests/incidents-app/package.json @@ -4,7 +4,7 @@ "dependencies": { "@cap-js/attachments": "file:../../.", "@cap-js/audit-logging": "^1.2.0", - "@cap-js/hana": "3.0.1", + "@cap-js/hana": ">=2.6.0", "@cap-js/postgres": "^2.2.0" }, "devDependencies": {