CDC Sink: Patching
-
A patch is a JavaScript script that customizes how CDC Sink applies a source-table change to RavenDB.
-
Use
Patchfor INSERT and UPDATE events.
It runs after column mapping and can transform the mapped RavenDB document before CDC Sink saves it. -
Use
OnDelete.Patchfor DELETE events.
It lets you add delete-handling logic, such as writing audit records, archiving documents, or updating aggregates. -
Patches are configured per table, for both root-table and embedded-table mappings.
-
In this article:
When patches run
| Event | Configure the script in | When it runs |
|---|---|---|
| INSERT | Patch | After column mapping |
| UPDATE | Patch | After column mapping, on the merged document |
| DELETE (root table) | OnDelete.Patch | Before the document is deleted |
| DELETE (embedded table) | OnDelete.Patch | On the parent document, after the embedded item is removed |
If OnDelete.IgnoreDeletes is enabled, the patch still runs,
but CDC Sink keeps the document or embedded item unless the script deletes it explicitly.
The patch context
Every patch script runs with three values in scope - this, $row, and $old -
and can use the load(id) helper function to read another RavenDB document from the current database.
Understanding what each one holds - and how they differ - is the key to writing correct patches.
| Object | Type | What it holds |
|---|---|---|
this | object | The RavenDB document currently being patched (the root document, or the parent document for an embedded table). Write changes to this when you want to modify that document. |
$row | object | The incoming SQL row from the CDC event, with all columns (mapped and unmapped). Read-only input. |
$old | object | null | The previous stored document or embedded item, as it was before this change.null when there is no previous value (a new document or embedded item).Read-only snapshot. |
load(id) | function | Loads a RavenDB document by ID from the current database. See loading related documents. |
The next section compares these values side by side, since $row, $old, and this are easy to confuse.
Additional document functions - put(id, document) and del(id) - are also available.
See capabilities and limitations for details.
Comparing $row, $old, and this
| Aspect | $row | $old | this |
|---|---|---|---|
| Represents | Incoming SQL row from the CDC event | Previous stored document or embedded item, before this change | RavenDB document currently being patched |
| Property names | Original SQL column names (customer_name) | RavenDB document property names (CustomerName) | RavenDB document property names (CustomerName) |
| Value types | Raw SQL types (see $row column types) | Stored JSON types (whatever was last written to RavenDB) | JSON values in the document being patched |
| On INSERT | The row being inserted | null - no prior state exists | The newly built document |
| On UPDATE | The row's new values | The document/item as it was before this update | The document after column mapping or embedded update |
| On DELETE | DELETE event data (see note below) | The document/item as it was before deletion | The document about to be deleted, or the parent document for an embedded delete |
| Mutable? | No - read-only input | No - read-only snapshot | Yes - write changes here to modify the current document |
-
$rowvsthis- source row vs RavenDB document:$rowis the raw SQL input: original column names, raw source values, including unmapped columns.
thisis the RavenDB document currently being patched, using RavenDB property names and JSON values.
Use$rowto read values from the source event, and write document changes tothis. -
$oldvsthis- previous state vs current patch target:$oldis the stored document or embedded item before this change;
thisis the document currently being patched.
Use$oldto detect what changed or to reverse a previous contribution (see delta computations).
Given this column mapping:
Columns =
[
new CdcColumnMapping() { Column = "customer_name", Name = "CustomerName" },
new CdcColumnMapping() { Column = "amount", Name = "Amount" },
]
In the patch script:
- javascript
// $row - SQL column names, raw SQL types (the incoming row)
$row.customer_name // string
$row.amount // number (from numeric/decimal column)
// $old - mapped property names, stored JSON types (the previous state)
$old?.CustomerName // string, or undefined when there is no previous value
$old?.Amount // number, or undefined when there is no previous value
// this - mapped property names, after column mapping for this event (the new state)
this.CustomerName // string (just set from $row.customer_name)
this.Amount // number (just set from $row.amount)
-
Unmapped columns - those not listed in
Columns- appear in$rowbut are not written by column mapping.
They appear inthisor later in$oldonly if a patch stores them in the document. -
For root-table DELETE patches, changes made to
thisare saved only when the delete is skipped,
for example withOnDelete.IgnoreDeletes. -
$rowon a PostgreSQL DELETE
With PostgreSQL's defaultREPLICA IDENTITY,$rowcontains only the primary-key columns on DELETE events.
If a DELETE patch needs values from the deleted row, prefer$old, which holds the previous RavenDB document or embedded item.
$row column types
$row exposes all SQL columns using their original column names (not the mapped RavenDB property names).
The JavaScript type of each value depends on the source database type.
The table below covers the PostgreSQL source provider.
Provider-specific type mappings can differ for SQL Server and MySQL/MariaDB.
PostgreSQL
| PostgreSQL Type | JavaScript Type in $row | Notes |
|---|---|---|
integer / bigint | number | |
real / double precision | number | |
numeric | number | |
boolean | boolean | |
date / timestamp / timestamptz | string | Date/time string, not a JavaScript Date |
uuid | string | |
varchar / text / char | string | |
bytea | string | Base64-encoded |
json / jsonb | string | In $row, JSON is raw text.Call JSON.parse() to access it as an object / array |
text[] / varchar[] / int[] / etc. | Array | Elements are always strings, even for numeric array types |
vector (pgvector) | Array | Elements are JavaScript numbers (floats) |
JSON/JSONB columns require explicit parsing:
- javascript
var meta = JSON.parse($row.metadata);
this.Priority = meta.priority;
this.Label = meta.label;
Array columns:
- javascript
this.TagCount = $row.tags.length;
this.FirstTag = $row.tags[0];
this.Tags = $row.tags;
// INT[] elements are strings - parse explicitly if numeric arithmetic is needed
var first = parseInt($row.scores[0], 10);
pgvector vector columns (elements are numbers, not strings):
- javascript
this.EmbeddingSum = $row.embedding.reduce(function(a, b) { return a + b; }, 0);
this.Embedding = $row.embedding;
Patch scope: root vs embedded
Root table patches operate on the root document:
Patch = "this.Status = $row.is_active ? 'Active' : 'Inactive';"
this is the Orders (or whatever root collection) document.
Embedded table patches operate on the parent document, not the embedded item:
// Patch on order_lines embedded table
Patch = "this.TotalQuantity = (this.Lines || []).reduce((s, l) => s + l.Quantity, 0);"
this is the parent Orders document.
This lets you recompute parent-level aggregates when an embedded item is inserted, updated, or deleted.
Embedded table patches run with this set to the owning RavenDB document that contains the embedded item,
not to the embedded item itself. $row is the incoming embedded-table row, and $old is the previous embedded item.
Capabilities and limitations
What patches can do:
- Read
this,$row, and$old - Load documents from the current database with
load(id) - Create or replace documents in the current database with
put(id, document) - Delete documents from the current database with
del(id) - Compute and transform property values
- Set and modify document metadata
- Use JavaScript logic such as conditionals, loops, and array methods
- Use built-in JavaScript objects such as
Date,Math,JSON, andArray
What patches cannot do:
- Make external HTTP calls
- Access the file system
- Load documents from other RavenDB databases
Idempotency and failover
After a failover, CDC Sink resumes from the last replicated @cdc-states checkpoint.
If the previous mentor node processed changes that had not yet reached that checkpoint on the new node, those source changes may be read again.
A patch is idempotent when applying the same source change more than once leaves the document in the same final state.
- javascript
// NOT idempotent - increments again on re-read
this.ViewCount = (this.ViewCount || 0) + 1;
// Idempotent - absolute value from SQL source
this.ViewCount = $row.view_count;
// Idempotent - delta via $old
const oldVal = $old?.Amount || 0;
const newVal = $row.amount || 0;
this.RunningTotal = (this.RunningTotal || 0) + (newVal - oldVal);
Column mapping itself is idempotent - writing the same mapped values again is safe.
Step limit
Patch scripts are bounded by a step quota rather than a time limit.
Each operation in the script (assignment, loop iteration, function call) consumes steps.
The limit is controlled by the Patching.MaxStepsForScript configuration setting.
See Patching Configuration.
If a patch exceeds the limit, the CDC operation fails and is retried.
Keep patches focused and efficient - avoid unnecessary loops, repeated document loads, and large in-script computations.
Test a patch
Validate a patch script against rows fetched from the configured source table before saving the task.
Testing runs CDC Sink mapping and the relevant Patch or OnDelete.Patch,
then returns the preview result without persisting any documents.
From Studio:
Use the Test tool in the CDC Sink task view.
This is the recommended way and requires no code.
See Test mapping for a step-by-step walkthrough.
Over HTTP:
The Test tool is backed by a REST endpoint you can call directly:
| Method | Endpoint | Auth |
|---|---|---|
POST | /databases/{databaseName}/admin/cdc-sink/test | DatabaseAdmin |
Test mode previews the selected root table mapping and the relevant patch script.
Linked and embedded tables configured under that table are not exercised.
Common scenarios
The following examples show common patching tasks:
- Column transformation
- Aggregation with embedded tables
- Delta computations
- Computed fields
- Document metadata
- Loading related documents
Column transformation
Transform or combine source columns into RavenDB document properties:
- javascript
this.FullName = ($row.first_name + ' ' + $row.last_name).trim();
this.Status = $row.is_active ? 'Active' : 'Inactive';
this.CreatedAt = new Date($row.created_unix * 1000).toISOString();
Aggregation with embedded tables
Recompute parent-level totals whenever an embedded item is inserted, updated, or deleted.
If you use a Patch to maintain an aggregate, you must also provide an OnDelete.Patch that updates the aggregate when an item is deleted.
Without it, deletes will leave the aggregate in an incorrect state.
- csharp
new CdcSinkEmbeddedTableConfig
{
SourceTableName = "order_lines",
PropertyName = "Lines",
PrimaryKeyColumns = ["line_id"],
JoinColumns = ["order_id"],
Columns =
[
new CdcColumnMapping() { Column = "line_id", Name = "LineId" },
new CdcColumnMapping() { Column = "quantity", Name = "Quantity" },
],
// Runs on INSERT and UPDATE - recomputes total from current Lines array
Patch = @"
this.TotalQuantity = (this.Lines || [])
.reduce((sum, line) => sum + (line.Quantity || 0), 0);
",
// REQUIRED: Runs on DELETE - recomputes after item is removed from array
OnDelete = new CdcSinkOnDeleteConfig
{
Patch = @"
this.TotalQuantity = (this.Lines || [])
.reduce((sum, line) => sum + (line.Quantity || 0), 0);
"
}
}
With the default delete behavior, the OnDelete.Patch runs after the item has already been removed from the array,
so re-summing this.Lines gives the correct post-deletion total.
Delta computations
Use $old to compute the difference between the previous embedded item value and the new source-row value,
so you can keep a running total without recomputing it from scratch.
Running-total patches that use $old must also provide an OnDelete.Patch that subtracts the deleted item's value.
Without it, deletes leave the running total incorrect.
- csharp
new CdcSinkEmbeddedTableConfig
{
SourceTableName = "invoice_lines",
PropertyName = "Lines",
PrimaryKeyColumns = ["line_id"],
JoinColumns = ["invoice_id"],
Columns =
[
new CdcColumnMapping() { Column = "line_id", Name = "LineId" },
new CdcColumnMapping() { Column = "amount", Name = "Amount" },
],
// INSERT: $old is null, so delta = new amount (0 → new)
// UPDATE: $old has previous Amount, delta = new - old
Patch = @"
const oldAmount = $old?.Amount || 0;
const newAmount = $row.amount || 0;
this.RunningTotal = (this.RunningTotal || 0) + (newAmount - oldAmount);
",
// REQUIRED: Subtract the deleted item's amount using $old
OnDelete = new CdcSinkOnDeleteConfig
{
Patch = @"
const deletedAmount = $old?.Amount || 0;
this.RunningTotal = (this.RunningTotal || 0) - deletedAmount;
"
}
}
In OnDelete.Patch, $old contains the embedded item's last known state before deletion.
Computed fields
Compute document fields from unmapped source columns that you don't want to store directly:
- csharp
Columns =
[
new CdcColumnMapping() { Column = "id", Name = "Id" },
new CdcColumnMapping() { Column = "name", Name = "Name" },
],
// base_price and tax_rate are NOT in Columns
Patch = @"
this.FinalPrice = $row.base_price * (1 + $row.tax_rate);
this.Discount = $row.is_vip ? $row.base_price * 0.1 : 0;
"
Document metadata
Patches can set or clear RavenDB document metadata, including expiration:
- javascript
this['@metadata'] = this['@metadata'] || {};
if ($row.expires_at) {
this['@metadata']['@expires'] = new Date($row.expires_at).toISOString();
} else {
delete this['@metadata']['@expires'];
}
this['@metadata']['SourceTable'] = 'orders';
Loading related documents
Use load() to load a related RavenDB document and denormalize its data:
- javascript
const customer = load('Customers/' + $row.customer_id);
if (customer) {
this.CustomerName = customer.Name;
this.CustomerEmail = customer.Email;
this.CustomerTier = customer.Tier;
} else {
// Related document does not exist yet, has not been synced yet, or was deleted
this.CustomerName = null;
this.CustomerEmail = null;
this.CustomerTier = null;
}
Use load() when you want to copy related document values into the current document at patch time.
The copied values are denormalized; later changes to the loaded document do not automatically update this document.
load() returns null if the document does not exist or has not yet been created by CDC Sink
(race condition when multiple tables are loading in parallel).
Always check for null before accessing properties.
When to use load():
- Denormalizing slowly-changing reference data (customer name, category, region)
- Capturing a snapshot of related data at insert time
Prefer linked tables when you only need to store a simple foreign-key reference by document ID.
They avoid denormalized-copy drift and the null-handling complexity of load().