Skip to main content
Version: 0.3.36

Git-Like Workflows

RaisinDB versions content the way Git versions files. Every write is a revision, a branch is a pointer to a revision, branches fork and merge, and tags name revisions. If you know Git, the mapping is direct:

GitRaisinDBNotes
CommitRevisionCreated by every write; identified by an HLC value such as 1788719765785-0
BranchBranchA name pointing at its newest revision
Fork / checkout -bCREATE BRANCH 'x' FROM 'main'Copies content and schema at the fork revision
CheckoutBranch in the addressUSE BRANCH, the {branch} URL segment, or db.onBranch()
LogRevision log, node historyRepository-wide commits, or one node's revisions
Diffcompare / diffCommits ahead and behind, or the changed nodes
MergeMERGE BRANCH 'x' INTO 'main'Fast-forward or three-way with conflict detection
TagTagAn immutable name for one revision
ResetSet branch headMoves the pointer; history is kept

One difference from Git: there is no working directory or staging area. A write is committed the moment it happens, on the branch you addressed. A SQL transaction can group several writes into one revision with a message, but a plain UPDATE is a commit too, with a generated message.

The examples below use SQL over HTTP. The same operations exist over the JavaScript client and the management API; see Branches and Tags.

Feature branch

Work in isolation, review, then merge.

-- fork from main
CREATE BRANCH 'feature/new-layout' FROM 'main';

Write on the branch by addressing it (POST /api/sql/myapp/feature%2Fnew-layout; a slash in a branch name is URL-encoded), or in one batch:

USE BRANCH 'feature/new-layout';
UPDATE 'content' SET properties = '{"title":"Hello","layout":"modern"}'::jsonb WHERE path = '/hello';

main is unchanged until the merge:

SHOW DIVERGENCE 'feature/new-layout' FROM 'main';
-- branch | base | ahead | behind | common_ancestor
-- feature/new-layout | main | 1 | 0 | 1788719729588-0

MERGE BRANCH 'feature/new-layout' INTO 'main' MESSAGE 'New layout';
DROP BRANCH 'feature/new-layout';

Environment branches

One branch per environment, promoted by merging in one direction.

CREATE BRANCH 'staging' FROM 'main';
CREATE BRANCH 'production' FROM 'main';
ALTER BRANCH 'production' SET PROTECTED TRUE;

Editors work on main. To release, merge main into staging, test against the staging branch address, then remove protection and merge into production:

MERGE BRANCH 'main' INTO 'staging' MESSAGE 'Release candidate';
ALTER BRANCH 'production' SET PROTECTED FALSE;
MERGE BRANCH 'staging' INTO 'production' MESSAGE 'Release 2026-09-06';
ALTER BRANCH 'production' SET PROTECTED TRUE;

If only part of staging should go live, copy the reviewed subtrees with copyNodes instead of merging the whole branch; see Working with Branches.

Editorial review

A writer drafts on a branch, an editor reviews the branch, and the approved result is merged.

CREATE BRANCH 'draft/article-123' FROM 'main';
USE BRANCH 'draft/article-123';
INSERT INTO 'content' (path, node_type, name, properties)
VALUES ('/blog/article-123', 'raisin:Page', 'article-123', '{"title":"Draft"}'::jsonb);

The editor reads the draft branch, sees exactly what changed, and merges:

SELECT path, properties->>'title' AS title FROM 'content' WHERE __branch = 'draft/article-123';
MERGE BRANCH 'draft/article-123' INTO 'main' MESSAGE 'Publish article 123';

Over HTTP the editor can call the diff endpoint for a per-node list of added, modified and deleted nodes before merging.

Releases with tags

Tag the revision you shipped so it can be read, compared or forked later.

const head = await db.branches().getHead('main');
await db.tags().create('v1.0', head.revision, 'First release');

// read content as it was at the release
await db.executeSql("SELECT path, properties->>'title' AS title FROM 'content' WHERE __revision = '" + head.revision + "'");

// hotfix on a branch forked at the release
await db.branches().create('hotfix/v1.0.1', { fromBranch: 'main', fromRevision: head.revision });

Concurrent edits and conflicts

Two branches can change different nodes freely; a three-way merge combines them. When both change the same node since the fork, the merge stops and returns the conflicting nodes with their base, target and source properties:

MERGE BRANCH 'user/jane' INTO 'main';
-- error: Merge has 1 conflict(s). Use SHOW CONFLICTS FOR MERGE 'user/jane' INTO 'main' to view details ...

SHOW CONFLICTS FOR MERGE 'user/jane' INTO 'main';
-- node_id | path | conflict_type | base_properties | target_properties | source_properties
-- 260584c1-... | | BothModified | {"title":"Hello v3"} | {"title":"Title from main"} | {"title":"Title from jane"}

Conflicts are detected per node, not per property. The merge is completed by sending one resolution per conflicted node, each naming the final properties, to the resolve-merge endpoint:

curl -X POST http://localhost:8080/api/management/repositories/default/myapp/branches/main/resolve-merge \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"source_branch": "user/jane",
"resolutions": [{
"node_id": "260584c1-...",
"resolution_type": "keep-theirs",
"resolved_properties": { "title": "Title from jane" }
}],
"message": "Merge jane",
"actor": "editor"
}'

See Merging Changes for the SQL form and the response.

Rolling back

A branch head can be moved to any earlier revision. Nothing is deleted, so it can be moved forward again.

const history = await db.workspace('content').nodes().historyByPath('/hello');
await db.branches().updateHead('main', history[1].revision); // main now reads as it did one revision ago

Agents on branches

The same model gives an AI agent a private copy of the data to work in: fork a branch per task, let the agent write there, inspect the diff, and merge or drop the branch. See Agent Memory with Branches.

Next Steps