cap-collaborative-draft
A cds-plugin that gives a CAP Node.js service the OData contract, tables and handlers Fiori elements needs for collaborative draft, so a second user joins an open draft instead of meeting HTTP 423.
CAP's lean draft belongs to one editor: a second user opening it is refused with HTTP 423, locked by another user. This plugin makes the draft shared — it adds the participant, field-lock and collaborative-state tables to the model, injects the CollaborativeDraftEnabled, DraftAccessType, DraftAdministrativeUser and ColDraftShare parts of the OData contract into $metadata, and prepends handlers so a second user joins the draft while a PATCH on a field another participant holds answers 409. Two annotations on the service entity switch it on, @odata.draft.enabled and @CollaborativeDraft.enabled: true, and entities without the second are unaffected. It stops at the backend contract: the participant avatar group and the discard dialog are UI5 snippets the consuming application adds to its own Object Page, and cross-participant conflicts found on activation are logged rather than merged. Live presence needs @cap-js-community/websocket@cap-js-community/websocketA WebSocket adapter for CDS services, speaking either the plain WebSocket protocol or Socket.IO, so a service can push instead of being polled.community org, cap, and that relay is per process, so without the adapter the plugin runs pull-only and with it one CAP instance still does not broadcast to another instance's participants.
Facts
Where it sits
Who is behind it
In the atlas: maintains 6, contributes to 5, wrote 1@sleibachmaintainer
Before choosing this
Conditions
LicenceSAP Developer License Agreement 3.2 CAP§ 1, 2, 3A cds-plugin is used with SAP CAP materials, or an artifact is explicitly identified as a CAP plugin or application.
- § 1 License. Productive customer applications are limited to SAP BTP or another SAP-licensed platform, and mass extraction to a non-SAP product is restricted except where solely required for SAP interoperability.
- § 2 Intellectual Property. The agreement retains SAP's rights in the covered materials and limits making APIs, tools or software available to third parties, while preserving ownership of customer applications subject to SAP's underlying rights.
- § 3 Artificial Intelligence Training. The agreement prohibits use of the covered materials and customer applications for training artificial-intelligence models or systems.
PracticalFunctional boundary
The maintainer marks this under active development; public APIs, annotation names and OData contracts may still change.
What it declares
Every artifact
- Type
- CDS plugin
- Origin
- Independently maintained
- Runtime
- Node.js
As a CDS plugin
- Declared for
- >=8.0
- Distributed through
- npm
Connections
Stopping concurrent edits from silently overwriting each other
Editing a record over several requests before it becomes real
WebSocket and Socket.IO adapter for CDS services
Download
Install
npm add cap-collaborative-draftDerived from the package name. No recipe recorded; the README has the configuration.
README
quoted from sleibach/cap-collaborative-draft under Apache-2.0, readcap-collaborative-draft
Active Development Notice This plugin is under active development. Public APIs, annotation names, OData contracts, and configuration keys may change without prior notice until a stable 1.0 release is published. Pin to an exact version in production and review the changelog before upgrading.
A cds-plugin that enables SAP Fiori Elements Collaborative Draft on CAP Node.js backends — a feature normally exclusive to ABAP RAP.
Collaborative Draft lets multiple authenticated users co-edit the same draft instance simultaneously, with live presence awareness, field-level locking, and automatic participant management. This plugin brings that capability to any CAP Node.js service by implementing the required OData contract, database schema, and handler logic — without requiring any ABAP infrastructure.
Table of Contents
- cap-collaborative-draft
- Table of Contents
- Overview
- Installation
- Quick Start
- How It Works
- OData Contract
- Configuration
- Fiori Elements Integration
- Multi-Instance Deployment
- Limitations and Known Gaps
- Compatibility
- Development and Testing
- License
Overview
SAP Fiori Elements Collaborative Draft is the standard mechanism for multi-user concurrent editing of draft objects in SAP enterprise applications. In ABAP RAP, it is implemented through a combination of:
- RAP collaborative draft toggle in the BOPF framework
- OData annotations (
@Common.DraftRoot.ShareAction,CollaborativeDraftEnabled,DraftAccessType) - ABAP Push Channel (WebSockets) for real-time presence relay
- OData actions for participant join, field lock, and share operations
This plugin replicates the full OData contract on CAP Node.js, allowing Fiori Elements clients to activate their collaborative draft UI against a CAP backend. The plugin is additive — existing entities without @CollaborativeDraft.enabled are completely unaffected.
Preview

Installation
npm add cap-collaborative-draft
Or pin to an exact version (recommended while the API is still under active development — see the notice at the top of this README):
npm add cap-collaborative-draft@1.0.0
The plugin is auto-discovered by CAP via the cds-plugin.js convention. No additional wiring, service registration, or cds.serve() call is required.
Source: github.com/sleibach/cap-collaborative-draft npm: npmjs.com/package/cap-collaborative-draft
Peer dependency: @sap/cds >= 8.0
Required peer dependency: @cap-js-community/websocket — the plugin registers a WebSocket relay service for real-time presence updates, participant avatars, and field-lock indicators. Without it, the Fiori Elements collaborative draft UI will not function: participants won't see each other, field locks won't appear, and no live updates will occur. The plugin will still start, but collaborative draft is effectively broken in pull-only mode.
Quick Start
1. Annotate your service entity
Add both @odata.draft.enabled and @CollaborativeDraft.enabled: true to the service-level entity projection. Both annotations must be on the service entity (not the underlying DB entity):
// srv/service.cds
using { my.orders } from '../db/schema';
service OrderService {
@odata.draft.enabled
@CollaborativeDraft.enabled: true
entity Orders as projection on my.orders.Orders {
*, Items
}
}
Annotation placement: Both annotations belong on the service-layer entity, not the database entity. Placing
@odata.draft.enabledon a database entity is non-standard in CDS and may produce unexpected draft table generation.@CollaborativeDraft.enabledannotations on database entities are silently ignored by this plugin.
2. Start your CAP application
cds watch
The plugin logs two info-level messages at startup:
[collab-draft] - cap-collaborative-draft plugin loaded
[collab-draft] - Collaborative draft enabled for OrderService [ Orders ]
3. Verify the OData contract
curl http://localhost:4004/odata/v4/order/$metadata | grep -E 'ColDraftShare|CollaborativeDraftEnabled|DraftAdministrativeUser'
You should see all three terms in the output, confirming the plugin has correctly augmented the metadata.
How It Works
The plugin hooks into four distinct stages of the CAP bootstrap lifecycle.
Phase 0 — Raw CSN Augmentation
Hook: cds.on('loaded')
At raw CSN stage (before cds.compile.for.nodejs), the plugin:
- Scans
csn.definitionsfor all service entities that carry both@CollaborativeDraft.enabled: trueand@odata.draft.enabled. Only entities with aqueryorprojectionproperty are considered (i.e., service projections — not bare database entities). - Creates
DRAFT.DraftParticipants,DRAFT.DraftFieldLocksandDRAFT.CollaborativeDraftStateentity definitions in the raw CSN if they do not already exist, causing CDS to deploy the corresponding database tables alongside the standard draft tables.DRAFT.CollaborativeDraftState(keyed byDraftUUID) holds the per-draft collaborative state —CollaborativeDraftEnabledandDraftAccessType. Modeling this state as a normal entity (rather than adding columns to the compiler-ownedDRAFT_DraftAdministrativeDatatable) means it is created by the regular deployment on any database, including SAP HANA Cloud / HDI where the runtime user has no DDL privileges. - Adds
@Common.DraftRoot.ShareActionannotation to each qualifying entity, naming the action<EntityShortName>_ColDraftShare.
Phase 1 — Compiled Model Augmentation
Hook: cds.on('served') (compiled model available)
After CDS compiles the model for Node.js, the plugin:
- Adds
CollaborativeDraftEnabled: BooleanandDraftAccessType: String(1)to theDRAFT.DraftAdministrativeDataelement map (and all service-projected copies of it) as virtual OData properties. They are exposed in$metadataand the OData payload but are not physical columns ofDRAFT_DraftAdministrativeData; theafter READ DraftAdministrativeDatahandler populates them at read time from theDRAFT.CollaborativeDraftStatetable. - Creates
DRAFT.DraftAdministrativeUseras a virtual entity (no persistence) and adds it as a contained navigation property onDraftAdministrativeData. Fiori Elements reads this navigation property to display the participant avatar group. - Creates
<ServiceNs>.ColDraftUsersas a virtual entity for each service. This entity backs the user search value help in the invite dialog. - Registers the
<EntityShortName>_ColDraftSharebound action definition on each collaborative entity, including itsUsers(Collection(ColDraftShareUser)) parameter andShareAll/IsDeltaUpdateflags. - Creates the
<ServiceNs>.ColDraftShareUsercomplex type used as the action parameter.
Phase 2 — $metadata Middleware
Hook: Express middleware registered via cds.on('bootstrap')
CAP generates $metadata XML from the compiled model. For certain aspects of the collaborative draft contract, runtime XML injection is more reliable than compiled model manipulation. The middleware intercepts every $metadata response and idempotently patches:
| Injection | Condition |
|---|---|
CollaborativeDraftEnabled + DraftAccessType properties | Added to DraftAdministrativeData EntityType if absent |
DraftAdministrativeUser NavigationProperty | Added to DraftAdministrativeData EntityType if absent |
DraftAdministrativeUser EntityType | Injected as fallback if not produced by model compiler |
NavigationPropertyBinding for DraftAdministrativeData/DraftAdministrativeUser | Added to all relevant EntitySets |
ColDraftShareUser ComplexType | Injected if absent |
ColDraftUsers EntityType and EntitySet | Injected if absent |
@Common.ValueList on ColDraftShareUser/UserID | Points to ColDraftUsers for the invite dialog user search |
@Common.WebSocketBaseURL / @Common.WebSocketChannel | Injected on the EntityContainer when WebSocket is available |
@Common.SideEffects for presence and draft change events | Injected on the collaborative entity's Annotations block |
Phase 3 — Handler Registration
Hook: cds.on('served'), registered via srv.prepend()
Handlers are prepended before CAP's built-in lean-draft handlers so they execute first. Only services that contain at least one collaborative draft entity receive these handlers.
| Event | Behavior |
|---|---|
EDIT | If a draft already exists for the entity, the current user joins it (added to the presence store) rather than receiving HTTP 423 "locked by another user". Sets InProcessByUser = '' to indicate shared ownership. |
after EDIT | Registers the user as originator if they created the draft, or as participant if they joined. Persists to DRAFT.DraftParticipants and runs the DB migration for CollaborativeDraftEnabled/DraftAccessType columns if not already present. |
before PATCH | Acquires field-level locks in DRAFT.DraftFieldLocks. Responds HTTP 409 if another participant holds a non-expired lock on any of the patched fields. Sets InProcessByUser to the current user to satisfy CAP's internal ownership check. |
before draftPrepare | Validates cross-participant field consistency. Logs conflicts; does not block activation. |
before draftActivate | Sets InProcessByUser to the current user so any participant can activate (not just the originator). |
after draftActivate | Cleans up all presence records and field locks for the draft UUID. |
before CANCEL | If the requesting user is the originator, the cancel proceeds for all participants. If a non-originator, removes only that participant from the presence store and returns HTTP 409 to keep the draft alive for others. |
after READ DraftAdministrativeData | Populates the virtual CollaborativeDraftEnabled (boolean) and DraftAccessType fields from the DRAFT.CollaborativeDraftState table and the presence store. Ensures the response always carries proper JSON booleans (not SQLite integers). |
READ DraftAdministrativeUser | Resolves participants from the in-memory presence store (with DB fallback) and maps them to the {DraftUUID, UserID, UserDescription, UserEditingState} shape expected by Fiori Elements. |
READ ColDraftUsers | Serves the user directory for the invite dialog value help. See User Directory. |
<EntityName>_ColDraftShare | Handles the bound share action. On self-join (ShareAll: true, empty Users): registers the calling user as participant, sets DraftAccessType = 'S' and CollaborativeDraftEnabled = 1, broadcasts CollaborativePresenceChanged via WebSocket. On explicit invite (Users non-empty): additionally emits the collab-draft:shareInvite event for app code to send notifications, and queues DraftMessages feedback so FE shows a confirmation toast. |
Presence Tracking
Implemented in lib/presence.ts.
In-memory store: Map<DraftUUID, Map<UserID, { displayName, lastSeen, isOriginator }>> — O(1) lookups per draft.
DB persistence: DRAFT.DraftParticipants — written on every join/heartbeat/leave so that presence survives server restarts. Loaded back into memory on startup via loadFromDB().
Eviction: A background setInterval (default: every 30 seconds) scans all entries and removes any participant whose lastSeen exceeds the configured TTL (default: 5 minutes). The timer is .unref()-ed so it does not block process exit. Evicted entries are also deleted from the DB.
| Function | Description |
|---|---|
join(draftUUID, userID, opts) | Adds or refreshes a participant. Preserves isOriginator on rejoin. |
heartbeat(draftUUID, userID) | Updates lastSeen without changing other fields. |
leave(draftUUID, userID) | Explicit removal (non-originator cancel, LEAVE WebSocket message). |
removeAll(draftUUID) | Removes all participants (draftActivate, originator cancel). |
getParticipants(draftUUID) | Returns the current participant list as ParticipantRecord[]. |
isOriginator(draftUUID, userID) | Returns whether the user created the draft. |
Field-Level Locking
Implemented in lib/field-locks.ts.
Locks are stored in DRAFT.DraftFieldLocks with a composite key of (DraftUUID, EntityKey, FieldName). Each lock record carries a LockedAt timestamp; locks older than the configured TTL (default: 120 seconds) are treated as expired and do not block new acquisitions.
The locking granularity corresponds to individual OData property names (field names as they appear in the PATCH request body). An entity key is serialized as a stable JSON string to handle composite keys.
| Function | Description |
|---|---|
acquireLocks(draftUUID, userID, entityKey, fields) | Attempts to acquire all specified locks atomically. Returns a result indicating which locks were acquired and which were blocked by another participant. |
releaseLocks(draftUUID, userID) | Releases all locks held by a user for a given draft. Called on participant leave and cancel. |
releaseAllLocks(draftUUID) | Releases all locks for a draft. Called on activation and originator cancel. |
getActiveLocks(draftUUID) | Returns non-expired locks. Used by Fiori Elements lock echo response. |
User Directory
The ColDraftUsers OData entity set backs the user search value help in the Fiori Elements invite dialog. It supports two operating modes:
Mode 1 — Entity-backed (recommended for production)
Point the plugin at any CDS entity or projection — a real database table is used, making it suitable for thousands of users. The READ ColDraftUsers handler delegates to cds.run(SELECT …) and pushes $filter/$search down to the database.
// package.json → "cds" block, or .cdsrc.json
{
"collab": {
"users": {
"entity": "OrderService.Users",
"userIdField": "email",
"userDescriptionField": "displayName"
}
}
}
| Property | Required | Default | Description |
|---|---|---|---|
entity | yes | — | Fully qualified CDS entity name (service projection or direct DB entity) |
userIdField | no | "UserID" | Source field mapped to UserID in the OData response |
userDescriptionField | no | "UserDescription" | Source field mapped to UserDescription in the OData response |
The entity only needs to be readable by the CAP runtime — it does not have to be exposed as its own OData entity set. A projection without @odata.draft.enabled on a dedicated service (or the same application service) works well:
// srv/service.cds
extend service OrderService {
// Read-only user directory, not directly exposed via FE but reachable by the plugin
@readonly entity Users as projection on my.Users {
key email as UserID,
displayName as UserDescription
};
}
When userIdField/userDescriptionField match the entity's actual column names, the AS aliases are redundant but harmless.
Mode 2 — Static map (development / small environments)
cds.env.collab.users as a plain object map, or the standard CAP mock auth user list:
Data sources (in priority order):
cds.env.collab.users— a plain object map of{ "<userID>": { "displayName": "…" } }.cds.env.requires.auth.users— the standard CAP mock auth user map (populated automatically in development from.cdsrc.json).- Empty list — if neither source is configured.
{
"collab": {
"users": {
"john.doe@example.com": { "displayName": "John Doe" },
"jane.smith@example.com": { "displayName": "Jane Smith" }
}
}
}
In static mode, $filter and $search are applied in memory after loading the full map.
WebSocket Integration
When @cap-js-community/websocket is installed and active, the plugin registers a CollabDraftWebSocketService on the /ws/collab-draft path. The WebSocket service relays the following message types (PCP-style JSON envelope):
| Message Type | Direction | Description |
|---|---|---|
JOIN | Client → Server | User opens a draft. Server responds with JOINECHO carrying current participant list. |
JOINECHO | Server → Client | Broadcasts participant list to all connected clients for the draft. |
LEAVE | Client → Server | User closes a draft or navigates away. Server broadcasts updated presence. |
LOCK | Client → Server | User focuses a field. Server acquires DB lock and broadcasts LOCKECHO. |
LOCKECHO | Server → Client | Broadcasts lock state to all clients. FE renders field lock indicators. |
UNLOCK | Client → Server | User blurs a field. Server releases the lock. |
CHANGE | Server → Client | Broadcasts a field value change to all other participants. |
ACTIVATE | Server → Client | Notifies all clients that the draft has been activated. |
DISCARD | Server → Client | Notifies all clients that the draft has been discarded. |
Without WebSocket, the plugin operates in pull-only mode: presence and lock state are correct and available via standard OData reads, but updates from other participants only appear after a client-triggered re-read (governed by @Common.SideEffects).
OData Contract
DraftAdministrativeData Extensions
Two properties are added to DraftAdministrativeData (and all service-projected copies):
| Property | OData Type | Description |
|---|---|---|
CollaborativeDraftEnabled | Edm.Boolean | true when the draft has active participants. Always returns a proper JSON boolean — never a SQLite integer. |
DraftAccessType | Edm.String (MaxLength=1) | "S" for shared (collaborative) drafts. null for standard exclusive drafts. |
DraftAdministrativeUser Navigation
A contained navigation property DraftAdministrativeUser is added to DraftAdministrativeData, pointing to a collection of:
| Property | Type | Description |
|---|---|---|
DraftUUID | Edm.Guid (key) | UUID of the draft |
UserID | Edm.String (key) | Authenticated user ID |
UserDescription | Edm.String | Display name |
UserEditingState | Edm.String | "N" (no changes) or "P" (pending changes) |
Fiori Elements reads this navigation property (with UserID ne '<currentUser>' filter) to display the participant avatar group and to populate the discard confirmation dialog.
ColDraftShare Bound Action
Added as a bound action on each collaborative entity:
<Action Name="Orders_ColDraftShare" IsBound="true">
<Parameter Name="bindingParameter" Type="Collection(OrderService.Orders)"/>
<Parameter Name="Users" Type="Collection(OrderService.ColDraftShareUser)" Nullable="true"/>
<Parameter Name="ShareAll" Type="Edm.Boolean" Nullable="true"/>
<Parameter Name="IsDeltaUpdate" Type="Edm.Boolean" Nullable="true"/>
</Action>
Referenced via @Common.DraftRoot.ShareAction = 'OrderService.Orders_ColDraftShare'. Fiori Elements calls this action from the invite dialog when the user clicks "Invite".
The action accepts:
ShareAll: truewith an emptyUsersarray — registers the calling user as a participant (self-join, called by the Fiori Elements client on draft open).- A non-empty
Usersarray — records the invitation intent, emits acollab-draft:shareInviteevent for application code to act on (see Events), and returns feedback messages viaDraftMessagesso Fiori Elements shows a confirmation toast instead of "No additional users were invited".
The plugin does not send any notification itself — the application is responsible for actually reaching out to the invited users (see Events).
ColDraftUsers Entity
A virtual (non-persisted) entity set used exclusively for the value help in the invite dialog:
| Property | Type |
|---|---|
UserID | Edm.String (key, MaxLength=256) |
UserDescription | Edm.String (MaxLength=256) |
The corresponding @Common.ValueList annotation is injected on ColDraftShareUser/UserID during $metadata patching, pointing the Fiori Elements ValueHelpDelegate to this entity set.
Configuration
All settings are namespaced under cds.collab and can be set in package.json ("cds" key) or .cdsrc.json:
{
"cds": {
"collab": {
"presenceTtlMs": 300000,
"lockTtlMs": 120000,
"cleanupIntervalMs": 30000,
"users": {
"john.doe@example.com": { "displayName": "John Doe" }
}
}
}
}
| Key | Type | Default | Description |
|---|---|---|---|
presenceTtlMs | number | 300000 (5 min) | Milliseconds of inactivity before a participant is automatically evicted from the presence store. |
lockTtlMs | number | 120000 (2 min) | Milliseconds after a PATCH before a field lock is considered stale and non-blocking. |
cleanupIntervalMs | number | 30000 (30 s) | How frequently the background cleanup timer scans for stale presence entries. |
users | object | undefined | Static user directory for the invite value help. Each key is a UserID; values may carry displayName. Falls back to cds.env.requires.auth.users if not set. |
Events
The plugin emits CDS events that application code can subscribe to. Register listeners in your CAP service file or in a custom cds-plugin.js.
collab-draft:shareInvite
Emitted when the Fiori Elements share dialog submits a list of users to invite (i.e. the Users parameter of ColDraftShare is non-empty). The plugin does not send any notification — this event is the hook for application code to do so.
const cds = require('@sap/cds')
const { SHARE_INVITE_EVENT } = require('cap-collaborative-draft/lib/draft-handlers')
cds.on(SHARE_INVITE_EVENT, ({ draftUUID, invitedBy, users }) => {
// users: Array<{ UserID: string, UserAccessRole?: string }>
for (const user of users) {
sendInviteEmail({ to: user.UserID, draftUUID, invitedBy })
}
})
Or use the event name string directly:
cds.on('collab-draft:shareInvite', ({ draftUUID, invitedBy, users }) => { ... })
| Property | Type | Description |
|---|---|---|
draftUUID | string | UUID of the draft being shared |
invitedBy | string | req.user.id of the user who triggered the invite |
users | Array<{ UserID, UserAccessRole? }> | Users selected in the invite dialog |
Fiori Elements Integration
Required Annotations
The plugin automatically adds all required annotations to $metadata. No manual annotation in service.cds is needed beyond the entity-level @CollaborativeDraft.enabled: true. Specifically, the plugin injects:
@Common.DraftRoot.ShareAction— triggers the invite button in the Object Page header@Common.WebSocketBaseURLand@Common.WebSocketChannel— enable WebSocket connectivity in FE (when WebSocket is available)@Common.SideEffectsforCollaborativePresenceChangedandCollaborativeDraftChangedevents — instructs FE when to re-read the entity binding
Invite Dialog and User Search
The Fiori Elements invite dialog (CollaborationDraft building block) renders a "Search User" input field backed by a value help. The value help reads from the ColDraftUsers entity set served by this plugin.
In development, users from cds.env.requires.auth.users are automatically available. In production, configure an entity-backed user directory via cds.env.collab.users.entity (see User Directory) so that search queries are pushed to the database rather than loaded in memory.
Participant Avatars
Participant avatars in the Object Page header are rendered by the CollaborationDraft building block (a custom Fiori Elements building block, available as a code snippet in ui5-snippets/CollaborationDraft.tsx). The building block reads from DraftAdministrativeData/DraftAdministrativeUser. This building block is not automatically injected by the plugin — it must be added to the Object Page by the consuming application, typically via a custom Object Page header section in manifest.json or a controller extension.
WebSocket Real-Time Updates
When @cap-js-community/websocket is installed, the Fiori Elements client connects to /ws/collab-draft immediately on draft open. The WebSocket URL parameters include the draft UUID and, optionally, user identity (userID, userName) when the FLP sandbox is started with ?useFLPUser=true.
Without WebSocket, Fiori Elements falls back gracefully to OData polling triggered by @Common.SideEffects. Presence and lock correctness are unaffected — only the real-time latency of updates changes.
Multi-Instance Deployment
The in-memory presence store (lib/presence.ts) is per process. In a horizontally scaled deployment with multiple CAP instances behind a load balancer:
- Presence reads via OData (
DraftAdministrativeData/DraftAdministrativeUser) are consistent across instances because they are backed byDRAFT.DraftParticipantsin the shared database. The in-memory store is a write-through cache that improves latency on the same instance. - Field lock checks read from
DRAFT.DraftFieldLocksin the database, so they are also cross-instance consistent. - WebSocket relay is per-instance. A participant on instance A will not receive WebSocket messages broadcast by instance B. For multi-instance real-time relay, a shared pub/sub channel (e.g., Redis) is required — this is outside the current scope of this plugin.
Recommendation for multi-instance deployments: Rely on OData polling (via @Common.SideEffects) rather than WebSocket for presence and lock updates. The OData contract is always consistent; only real-time latency is affected.
Limitations and Known Gaps
| Area | Status | Notes |
|---|---|---|
| Single-instance in-memory presence | Partial | Cross-instance consistent via DB; real-time WebSocket relay is per-instance only. |
| WebSocket multi-instance pub/sub | Not implemented | Requires Redis or equivalent adapter. |
Conflict merge on draftActivate | Logging only | Cross-participant field conflicts are detected and logged; no automatic merge or user-facing conflict resolution UI. |
UserDescription in mock users | ID = Description | CAP mock auth users do not carry display names; UserDescription equals UserID unless cds.collab.users is configured. |
CollaborationDraft building block | Not auto-injected | The participant avatar group and invite button require a custom Object Page fragment. See ui5-snippets/CollaborationDraft.tsx. |
CollaborationDiscardDialog | Not auto-injected | The "other users are editing" discard confirmation dialog requires a custom controller extension. See ui5-snippets/CollaborationDiscardDialog.tsx. |
| PATCH field lock granularity | Property-level | Locks are per property name. Composite nested entity locking (child entities, associations) is not yet implemented. |
Compatibility
| Dependency | Version |
|---|---|
@sap/cds | >= 8.0 |
| Node.js | >= 18 |
@cap-js/sqlite | >= 2.0 (development / testing) |
@cap-js/hana / SAP HANA Cloud | Supported (production). All plugin tables are part of the deployed model, so they are created by cds deploy / HDI deploy — no runtime DDL is executed. |
@cap-js-community/websocket | >= 1.0 (optional, for real-time relay) |
Fiori Elements (sap.fe.templates) | >= 1.120 (SAPUI5 1.120+) |
The plugin is tested against
@sap/cds8.x and SAPUI5 1.146. Compatibility with earlier versions of either dependency is not guaranteed.
Development and Testing
Starting the test app
cd test/app && npx cds watch
Open the Fiori Launchpad sandbox at:
http://localhost:4004/orders/webapp/test/flpSandbox.html?useFLPUser=true
The ?useFLPUser=true parameter instructs the WebSocket client to pass the FLP user identity as URL parameters on the WebSocket connection URL.
Verifying the OData contract
# Check that all collaborative draft annotations are present
curl http://localhost:4004/odata/v4/order/$metadata \
| grep -E 'ColDraftShare|CollaborativeDraftEnabled|DraftAdministrativeUser|ColDraftUsers|ShareAction'
# Check DraftAdministrativeData for a specific draft
curl "http://localhost:4004/odata/v4/order/Orders(ID=<uuid>,IsActiveEntity=false)/DraftAdministrativeData" \
| jq '{CollaborativeDraftEnabled, DraftAccessType}'
# List available users for the invite dialog
curl http://localhost:4004/odata/v4/order/ColDraftUsers | jq '.value[]'
# Check WebSocket connectivity
wscat -c "ws://localhost:4004/ws/collab-draft?draft=<draftUUID>"
Building from source
The plugin is authored in TypeScript and compiled to CommonJS in dist/:
npm run build # tsc
cds-plugin.js is a thin shim (module.exports = require('./dist/cds-plugin')). CAP discovers it by filename convention.
Running tests
npm test # Jest integration tests (SQLite, in-memory)
npx playwright test # E2E browser tests (requires running test app)
Testing against SAP HANA (HDI)
The plugin's persistence relies on tables being created by the deployer, never by runtime DDL — because an HDI container's runtime user has no DDL privileges. The following opt-in suite verifies that contract against a real HANA Cloud HDI container.
# 1. Provide an HDI service key (writes the gitignored test/app/default-env.json)
# Copy the service-key JSON to your clipboard, then:
cd test/app && pbpaste | node ../../scripts/wrap-hana-binding.mjs && cd ../..
# 2. Deploy the schema once (runs as the privileged deploy user via HDI)
npm run hana:deploy
# 3a. Run the smoke test (plain Node — recommended)
npm run hana:smoke
# 3b. …or the gated jest test (CI / environments with a healthy jest setup)
npm run test:hana
What the suite asserts, connected as the DML-only runtime user:
- the modeled
DRAFT.CollaborativeDraftStatetable exists (created at deploy time) and is queryable, - the collaborative read/write round-trip works (the operation the old
ALTER TABLEapproach broke), - a raw
ALTER TABLEis rejected withinsufficient privilege— proving runtime DDL is impossible on HDI and that the modeled-table approach is required.
The suite auto-skips unless HANA_TEST=1 and test/app/default-env.json are present, so
the default npm test stays on SQLite. Never commit the service key — default-env.json
is gitignored.
Note:
npm run hana:smokeis a plain-Node runner provided because the jest harness can hang during database bootstrap in some local environments.npm run test:hanais the equivalent jest test for CI.
License
Apache-2.0
History
Others providing
- com.sap.cds:cds-services-bomcom.sap.cds:cds-services-bomThe Maven bill of materials for CAP Java, pinning every com.sap.cds module in a release, from the API and core runtime to the Spring Boot starters and the CQN engine, to the same version.sap official, capMaven bill of materials pinning CAP Java module versions
- cap-handler-frameworkcap-handler-frameworkConvention-based handler layer for CAP: methods named beforeCreate, onRead or afterUpdate are mapped automatically, with the draft lifecycle given its own explicit hooks rather than folded into the active-entity ones.independent, capConvention-based handler layer for CAP with draft lifecycle hooks
- @rizing/cds-extension@rizing/cds-extensionA CAP extension, CDSX, that puts a draft-capable facade in front of an OData service imported into CAP, so Fiori elements can edit records that live in another system as drafts.partner, capCAP plugin putting a draft-capable facade in front of imported OData
- @sap/cds-fiori@sap/cds-fioriFiori draft handling and UI-facing behaviour for CAP services.sap official, capFiori draft handling and UI-facing behaviour for CAP services
- @sap/cds@sap/cdsThe CAP Node.js runtime, which compiles the CDS model, serves the services declared in it, answers generic requests before any handler runs, and loads every cds-plugin.sap official, capCAP Node.js runtime
- com.sap.cds:cds-services-bomcom.sap.cds:cds-services-bomThe Maven bill of materials for CAP Java, pinning every com.sap.cds module in a release, from the API and core runtime to the Spring Boot starters and the CQN engine, to the same version.sap official, capMaven bill of materials pinning CAP Java module versions
- @sap/cds@sap/cdsThe CAP Node.js runtime, which compiles the CDS model, serves the services declared in it, answers generic requests before any handler runs, and loads every cds-plugin.sap official, capCAP Node.js runtime
Details
Sources
Sources are graph entities: the same page may back several entries, and an unreachable source flags every row that rests on it.
Compatibility and provenance
Flavors
“A `cds-plugin` that enables **SAP Fiori Elements Collaborative Draft** on CAP Node.js backends — a feature normally exclusive to ABAP RAP.” — sleibach/cap-collaborative-draft — README
History in the atlas
- capability:draft-handling, capability:concurrency-control
- A cds-plugin that gives a CAP Node.js service the OData contract, tables and handlers Fiori elements needs for collaborative draft, so a second user joins an open draft instead of meeting HTTP 423.
- CAP's lean draft belongs to one editor: a second user opening it is refused with HTTP 423, locked by another user. …
- The README names it a required peer dependency for the real-time relay while its own compatibility table calls it optional; recorded as conditional on real-time presence, with both sentences quoted.
- The README and the package's peerDependencies both state @sap/cds >= 8.0; the entry carried the runtime and not the range.
- Stated rather than left to the cds-plugin fallback: the README's first line says CAP Node.js backends.
- First seen.
For your agent
- Ref
artifact:sleibach-cap-collaborative-draft- JSON
- /artifacts/sleibach-cap-collaborative-draft.json
- MCP
- Connecting an agent