Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions base/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,28 @@ func (me *MultiError) ErrorOrNil() error {
}
return me
}

const syncFnDryRunErrorPrefix = "Error returned from Sync Function"

// SyncFnDryRunError is returned when the sync function dry run returns an error.
// It wraps the original error for errors.Is and the type supports errors.As
type SyncFnDryRunError struct {
Err error
}

func (e *SyncFnDryRunError) Error() string {
if e == nil {
return syncFnDryRunErrorPrefix
}
if e.Err == nil {
return syncFnDryRunErrorPrefix
}
return syncFnDryRunErrorPrefix + ": " + e.Err.Error()
}

func (e *SyncFnDryRunError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
101 changes: 27 additions & 74 deletions db/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -1694,90 +1694,43 @@ 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
}

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
}
generation++

// Create newDoc which will be used to pass around Body
newDoc := &Document{
ID: docID,
}
// Pull out attachments
newDoc.SetAttachments(GetBodyAttachments(body))
delete(body, BodyAttachments)

delete(body, BodyRevisions)
// SyncFnDryrun Runs the given document body through a sync function and returns expiry, channels doc was placed in,
// access map for users, roles, handler errors and sync fn exceptions.
// If syncFn is provided, it will be used instead of the one configured on the database.
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, newDoc, oldDoc *Document, syncFn string) (*channels.ChannelMapperOutput, error) {

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

// We needed to keep _deleted around in the body until we generated a rev ID, but now we can ditch it.
_, isDeleted := body[BodyDeleted]
if isDeleted {
delete(body, BodyDeleted)
}

// and now we can finally update the newDoc body to be without any special properties
newDoc.UpdateBody(body)

// If no special properties were stripped and document wasn't deleted, the canonical bytes represent the current
// body. In this scenario, store canonical bytes as newDoc._rawBody
if !wasStripped && !isDeleted {
newDoc._rawBody = canonicalBytesForRevID
}

newRev := CreateRevIDWithBytes(generation, matchRev, canonicalBytesForRevID)
newDoc.RevID = newRev
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 {
return nil, &base.SyncFnDryRunError{Err: 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 {

return nil, &base.SyncFnDryRunError{Err: 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
30 changes: 27 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,40 @@
# the file licenses/APL2.txt.
parameters:
- $ref: ../../components/parameters.yaml#/keyspace
get:
- $ref: ../../components/parameters.yaml#/doc_id
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
84 changes: 77 additions & 7 deletions rest/diagnostic_doc_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ licenses/APL2.txt.
package rest

import (
"errors"
"fmt"
"net/http"

Expand All @@ -33,6 +34,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 @@ -70,20 +76,84 @@ func (h *handler) handleGetDocChannels() error {
func (h *handler) handleSyncFnDryRun() error {
docid := h.getQuery("doc_id")

body, err := h.readDocument()
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.

if err != nil {
if docid == "" {
return fmt.Errorf("no doc id provided for dry run and error reading body: %s", err)
return base.HTTPErrorf(http.StatusBadRequest, "Error reading sync function payload: %v", err)
}

if syncDryRunPayload.Doc == nil && docid == "" {
return base.HTTPErrorf(http.StatusBadRequest, "no docid or document provided")
}

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)
}

delete(syncDryRunPayload.Doc, db.BodyId)

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

// Create newDoc which will be used to pass around Body
newDoc := &db.Document{
ID: docid,
}
// Pull attachments
newDoc.SetAttachments(db.GetBodyAttachments(syncDryRunPayload.Doc))
delete(syncDryRunPayload.Doc, db.BodyAttachments)
delete(syncDryRunPayload.Doc, db.BodyRevisions)

output, err, syncFnErr := h.collection.SyncFnDryrun(h.ctx(), body, docid)
if _, ok := syncDryRunPayload.Doc[base.SyncPropertyName]; ok {
return base.HTTPErrorf(http.StatusBadRequest, "document-top level property '_sync' is a reserved internal property")
}

db.StripInternalProperties(syncDryRunPayload.Doc)

// We needed to keep _deleted around in the body until we generate rev ID, but now it can be removed
_, isDeleted := syncDryRunPayload.Doc[db.BodyDeleted]
if isDeleted {
delete(syncDryRunPayload.Doc, db.BodyDeleted)
}

//update the newDoc body to be without any special properties
newDoc.UpdateBody(syncDryRunPayload.Doc)

rawDocBytes, err := newDoc.BodyBytes(h.ctx())
if err != nil {
return err
return base.HTTPErrorf(http.StatusBadRequest, "Error marshalling document: %v", err)
}
if syncFnErr != nil {

newRev := db.CreateRevIDWithBytes(generation, matchRev, rawDocBytes)
newDoc.RevID = newRev

output, err := h.collection.SyncFnDryrun(h.ctx(), newDoc, oldDoc, syncDryRunPayload.Function)
if err != nil {
var syncFnDryRunErr *base.SyncFnDryRunError
if !errors.As(err, &syncFnDryRunErr) {
return err
}

errMsg := syncFnDryRunErr.Error()
resp := SyncFnDryRun{
Exception: syncFnErr.Error(),
Exception: errMsg,
}
h.writeJSON(resp)
return nil
Expand Down
Loading