Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions base/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ var (

// ErrInvalidJSON is returned when the JSON being unmarshalled cannot be parsed.
ErrInvalidJSON = HTTPErrorf(http.StatusBadRequest, "Invalid JSON")

// ErrSyncFnDryRun is returned when the Sync Function Dry Run returns an error while computing the access
ErrSyncFnDryRun = &sgError{"Error returned from Sync Function:"}
)

func (e *sgError) Error() string {
Expand Down
59 changes: 29 additions & 30 deletions db/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -1694,37 +1694,18 @@ func (db *DatabaseCollectionWithUser) PutExistingRevWithBody(ctx context.Context

}

// SyncFnDryrun Runs a document through the sync function and returns expiry, channels doc was placed in, access map for users, roles, handler errors and sync fn exceptions
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, body Body, docID string) (*channels.ChannelMapperOutput, error, error) {
doc := &Document{
ID: docID,
_body: body,
}
oldDoc := doc
if docID != "" {
if docInBucket, err := db.GetDocument(ctx, docID, DocUnmarshalAll); err == nil {
oldDoc = docInBucket
if doc._body == nil {
body = oldDoc.Body(ctx)
doc._body = body
// If no body is given, use doc in bucket as doc with no old doc
oldDoc._body = nil
}
doc._body[BodyRev] = oldDoc.SyncData.GetRevTreeID()
} else {
return nil, err, nil
}
} else {
oldDoc._body = nil
}
// SyncFnDryrun Runs a document through the sync function and returns expiry, channels doc was placed in, access map for users, roles, handler errors and sync fn exceptions.
// If a docID is a non empty string, the document will be fetched from the bucket, otherwise the body will be used. If both are specified, this function returns an error.
// The first error return value represents an error that occurs before the sync function is run. The second error return value represents an exception from the sync function.
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, oldDoc *Document, body Body, docID, syncFn string) (*channels.ChannelMapperOutput, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, docID is now unused and is making the usage of the function ambiguous without reading the implementation.

Suggested change
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, oldDoc *Document, body Body, docID, syncFn string) (*channels.ChannelMapperOutput, error) {
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, oldDoc *Document, body Body, syncFn string) (*channels.ChannelMapperOutput, error) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc ID is required for the preprocessing of the newDoc. I've moved all the preprocessing inside the handler. I wanted to know if its okay to do all the preprocessing in the handler instead of the Dry run method.


delete(body, BodyId)

// Get the revision ID to match, and the new generation number:
matchRev, _ := body[BodyRev].(string)
generation, _ := ParseRevID(ctx, matchRev)
if generation < 0 {
return nil, base.HTTPErrorf(http.StatusBadRequest, "Invalid revision ID"), nil
return nil, base.HTTPErrorf(http.StatusBadRequest, "Invalid revision ID")
}
generation++

Expand All @@ -1740,12 +1721,12 @@ func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, body Bod

err := validateAPIDocUpdate(body)
if err != nil {
return nil, err, nil
return nil, err
}
bodyWithoutInternalProps, wasStripped := StripInternalProperties(body)
canonicalBytesForRevID, err := base.JSONMarshalCanonical(bodyWithoutInternalProps)
if err != nil {
return nil, err, nil
return nil, err
}

// We needed to keep _deleted around in the body until we generated a rev ID, but now we can ditch it.
Expand All @@ -1768,16 +1749,34 @@ func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, body Bod
mutableBody, metaMap, _, err := db.prepareSyncFn(oldDoc, newDoc)
if err != nil {
base.InfofCtx(ctx, base.KeyDiagnostic, "Failed to prepare to run sync function: %v", err)
return nil, err, nil
return nil, err
}

syncOptions, err := MakeUserCtx(db.user, db.ScopeName, db.Name)
if err != nil {
return nil, err, nil
return nil, err
}
var output *channels.ChannelMapperOutput
var syncErr error
if syncFn == "" {
output, err = db.ChannelMapper.MapToChannelsAndAccess(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)
if err != nil {
syncErr = fmt.Errorf("%s%s", base.ErrSyncFnDryRun, err)
}
} else {
jsTimeout := time.Duration(base.DefaultJavascriptTimeoutSecs) * time.Second
syncRunner, err := channels.NewSyncRunner(ctx, syncFn, jsTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create sync runner: %v", err)
}
jsOutput, err := syncRunner.Call(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)
if err != nil {
syncErr = fmt.Errorf("%s%s", base.ErrSyncFnDryRun, err)
}
output = jsOutput.(*channels.ChannelMapperOutput)
}
output, err := db.ChannelMapper.MapToChannelsAndAccess(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)

return output, nil, err
return output, syncErr
}

// revTreeConflictCheck checks for conflicts in the rev tree history and returns the parent revid, currentRevIndex
Expand Down
29 changes: 26 additions & 3 deletions docs/api/paths/diagnostic/keyspace-sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,39 @@
# the file licenses/APL2.txt.
parameters:
- $ref: ../../components/parameters.yaml#/keyspace
get:
post:
summary: Run a doc body through the sync function and return sync data.
description: |-
Run a document body through the sync function and return document sync data.
Runs a document body through the sync function and returns document sync
data. If no custom sync function is provided in the request body, the
default or user-defined sync function for the collection is used.
| Document | DocID | Behaviour |
| -------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Yes | No | The document passed will be considered as newDoc and oldDoc will be empty |
| Yes | Yes | The document passed in the body will be newDoc and DocID will be read from the bucket/collection and will be passed as the oldDoc. If DocID doesn't exist, then oldDoc will be empty |
| No | No | Will throw an error |
| No | Yes | The docID will be passed in as the newDoc and oldDoc will be empty. If the document is not found, an error will be returned |

* Sync Gateway Application Read Only
requestBody:
content:
application/json:
schema:
$ref: ../../components/schemas.yaml#/Document
type: object
properties:
sync_function:
description: |-
A JavaScript function that defines custom access, channel, and
validation logic for documents. This function will be evaluated
by the Sync Gateway to determine document routing, access
grants, and validation outcomes during synchronization.
type: string
example: |-
function (doc, oldDoc) {
channel(doc.channels);
}
doc:
$ref: ../../components/schemas.yaml#/Document
responses:
'200':
description: Document Processed by sync function successfully
Expand Down
54 changes: 42 additions & 12 deletions rest/diagnostic_doc_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ package rest

import (
"fmt"
"mime"
"net/http"
"strings"

"github.com/couchbase/sync_gateway/auth"
"github.com/couchbase/sync_gateway/base"
Expand All @@ -33,6 +35,11 @@ type ImportFilterDryRun struct {
Error string `json:"error"`
}

type SyncFnDryRunPayload struct {
Function string `json:"sync_function"`
Doc db.Body `json:"doc,omitempty"`
}

func populateDocChannelInfo(doc db.Document) map[string][]auth.GrantHistorySequencePair {
resp := make(map[string][]auth.GrantHistorySequencePair, len(doc.Channels))

Expand Down Expand Up @@ -69,24 +76,47 @@ func (h *handler) handleGetDocChannels() error {
// If docid is specified and the document does not exist in the bucket, it will return error
func (h *handler) handleSyncFnDryRun() error {
docid := h.getQuery("doc_id")
contentType, _, _ := mime.ParseMediaType(h.rq.Header.Get("Content-Type"))

body, err := h.readDocument()
if err != nil {
if docid == "" {
return fmt.Errorf("no doc id provided for dry run and error reading body: %s", err)
if contentType != "application/json" && contentType != "" {
return base.HTTPErrorf(http.StatusUnsupportedMediaType, "Invalid Content-Type header: %s. Needs to be empty or application/json", contentType)
}

var syncDryRunPayload SyncFnDryRunPayload
err := h.readJSONInto(&syncDryRunPayload)
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling logic is unclear. If docid is provided but there's an error reading the JSON payload, the error is silently ignored. Consider either handling the error consistently regardless of docid, or add a clear comment explaining why the error is only relevant when docid is empty.

Suggested change
err := h.readJSONInto(&syncDryRunPayload)
err := h.readJSONInto(&syncDryRunPayload)
// Only require a valid JSON payload if docid is not provided.
// If docid is provided, the sync function will use the document from the bucket, and the payload is optional.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this comment.

Based on the table in the API documentation describing how this endpoint works - in the case where you provide a doc ID to act as oldDoc, and provide some invalid JSON for doc - this error will be a silent failure (running the sync function on an empty body).

This should be able to be covered by a unit test case.

// Only require a valid JSON payload if docid is not provided.
// If docid is provided, the sync function will use the document from the bucket, and the payload is optional.
if err != nil && docid == "" {
return base.HTTPErrorf(http.StatusBadRequest, "Error reading sync function payload: %v", err)
}

oldDoc := &db.Document{ID: docid}
oldDoc.UpdateBody(syncDryRunPayload.Doc)
if docid != "" {
if docInbucket, err := h.collection.GetDocument(h.ctx(), docid, db.DocUnmarshalAll); err == nil {
oldDoc = docInbucket
if len(syncDryRunPayload.Doc) == 0 {
syncDryRunPayload.Doc = oldDoc.Body(h.ctx())
oldDoc.UpdateBody(nil)
}
} else {
return base.HTTPErrorf(http.StatusNotFound, "Error reading document: %v", err)
}
} else {
oldDoc.UpdateBody(nil)
}

output, err, syncFnErr := h.collection.SyncFnDryrun(h.ctx(), body, docid)
output, err := h.collection.SyncFnDryrun(h.ctx(), oldDoc, syncDryRunPayload.Doc, docid, syncDryRunPayload.Function)
if err != nil {
return err
}
if syncFnErr != nil {
resp := SyncFnDryRun{
Exception: syncFnErr.Error(),
if strings.Contains(err.Error(), base.ErrSyncFnDryRun.Error()) {
errMsg := strings.ReplaceAll(err.Error(), base.ErrSyncFnDryRun.Error(), "")
resp := SyncFnDryRun{
Exception: errMsg,
}
h.writeJSON(resp)
return nil
}
h.writeJSON(resp)
return nil
return err
}
errorMsg := ""
if output.Rejection != nil {
Expand Down
Loading