Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ jobs:

steps:

- name: install git for alpine container
if: matrix.container
run: apk add --no-cache git

- uses: actions/checkout@v4.2.2
with:
submodules: true

- name: android setup java
if: matrix.name == 'android-aar'
Expand Down Expand Up @@ -234,6 +240,8 @@ jobs:
steps:

- uses: actions/checkout@v4.2.2
with:
submodules: true

- name: build and start postgresql container
run: make postgres-docker-rebuild
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "modules/fractional-indexing"]
path = modules/fractional-indexing
url = https://github.com/sqliteai/fractional-indexing
65 changes: 65 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ This document provides a reference for the SQLite functions provided by the `sql
- [`cloudsync_is_enabled()`](#cloudsync_is_enabledtable_name)
- [`cloudsync_cleanup()`](#cloudsync_cleanuptable_name)
- [`cloudsync_terminate()`](#cloudsync_terminate)
- [Block-Level LWW Functions](#block-level-lww-functions)
- [`cloudsync_set_column()`](#cloudsync_set_columntable_name-col_name-key-value)
- [`cloudsync_text_materialize()`](#cloudsync_text_materializetable_name-col_name-pk_values)
- [Helper Functions](#helper-functions)
- [`cloudsync_version()`](#cloudsync_version)
- [`cloudsync_siteid()`](#cloudsync_siteid)
Expand Down Expand Up @@ -173,6 +176,68 @@ SELECT cloudsync_terminate();

---

## Block-Level LWW Functions

### `cloudsync_set_column(table_name, col_name, key, value)`

**Description:** Configures per-column settings for a synchronized table. This function is primarily used to enable **block-level LWW** on text columns, allowing fine-grained conflict resolution at the line (or paragraph) level instead of the entire cell.

When block-level LWW is enabled on a column, INSERT and UPDATE operations automatically split the text into blocks using a delimiter (default: newline `\n`) and track each block independently. During sync, changes are merged block-by-block, so concurrent edits to different parts of the same text are preserved.

**Parameters:**

- `table_name` (TEXT): The name of the synchronized table.
- `col_name` (TEXT): The name of the text column to configure.
- `key` (TEXT): The setting key. Supported keys:
- `'algo'` — Set the column algorithm. Use value `'block'` to enable block-level LWW.
- `'delimiter'` — Set the block delimiter string. Only applies to columns with block-level LWW enabled.
- `value` (TEXT): The setting value.

**Returns:** None.

**Example:**

```sql
-- Enable block-level LWW on a column (splits text by newline by default)
SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');

-- Set a custom delimiter (e.g., double newline for paragraph-level tracking)
SELECT cloudsync_set_column('notes', 'body', 'delimiter', '

');
```

---

### `cloudsync_text_materialize(table_name, col_name, pk_values...)`

**Description:** Reconstructs the full text of a block-level LWW column from its individual blocks and writes the result back to the base table column. This is useful after a merge operation to ensure the column contains the up-to-date materialized text.

After a sync/merge, the column is updated automatically. This function is primarily useful for manual materialization or debugging.

**Parameters:**

- `table_name` (TEXT): The name of the table.
- `col_name` (TEXT): The name of the block-level LWW column.
- `pk_values...` (variadic): The primary key values identifying the row. For composite primary keys, pass each key value as a separate argument in declaration order.

**Returns:** `1` on success.

**Example:**

```sql
-- Materialize the body column for a specific row
SELECT cloudsync_text_materialize('notes', 'body', 'note-001');

-- With a composite primary key (e.g., PRIMARY KEY (tenant_id, doc_id))
SELECT cloudsync_text_materialize('docs', 'body', 'tenant-1', 'doc-001');

-- Read the materialized text
SELECT body FROM notes WHERE id = 'note-001';
```

---

## Helper Functions

### `cloudsync_version()`
Expand Down
22 changes: 13 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ MAKEFLAGS += -j$(CPUS)

# Compiler and flags
CC = gcc
CFLAGS = -Wall -Wextra -Wno-unused-parameter -I$(SRC_DIR) -I$(SRC_DIR)/sqlite -I$(SRC_DIR)/postgresql -I$(SQLITE_DIR) -I$(CURL_DIR)/include
CFLAGS = -Wall -Wextra -Wno-unused-parameter -I$(SRC_DIR) -I$(SRC_DIR)/sqlite -I$(SRC_DIR)/postgresql -I$(SRC_DIR)/network -I$(SQLITE_DIR) -I$(CURL_DIR)/include -Imodules/fractional-indexing
T_CFLAGS = $(CFLAGS) -DSQLITE_CORE -DCLOUDSYNC_UNITTEST -DCLOUDSYNC_OMIT_NETWORK -DCLOUDSYNC_OMIT_PRINT_RESULT
COVERAGE = false
ifndef NATIVE_NETWORK
Expand All @@ -46,7 +46,9 @@ POSTGRES_IMPL_DIR = $(SRC_DIR)/postgresql
DIST_DIR = dist
TEST_DIR = test
SQLITE_DIR = sqlite
VPATH = $(SRC_DIR):$(SQLITE_IMPL_DIR):$(POSTGRES_IMPL_DIR):$(SQLITE_DIR):$(TEST_DIR)
FI_DIR = modules/fractional-indexing
NETWORK_DIR = $(SRC_DIR)/network
VPATH = $(SRC_DIR):$(SQLITE_IMPL_DIR):$(POSTGRES_IMPL_DIR):$(NETWORK_DIR):$(SQLITE_DIR):$(TEST_DIR):$(FI_DIR)
BUILD_RELEASE = build/release
BUILD_TEST = build/test
BUILD_DIRS = $(BUILD_TEST) $(BUILD_RELEASE)
Expand All @@ -62,17 +64,19 @@ ifeq ($(PLATFORM),android)
endif

# Multi-platform source files (at src/ root) - exclude database_*.c as they're in subdirs
CORE_SRC = $(filter-out $(SRC_DIR)/database_%.c, $(wildcard $(SRC_DIR)/*.c))
CORE_SRC = $(filter-out $(SRC_DIR)/database_%.c, $(wildcard $(SRC_DIR)/*.c)) $(wildcard $(NETWORK_DIR)/*.c)
# SQLite-specific files
SQLITE_SRC = $(wildcard $(SQLITE_IMPL_DIR)/*.c)
# Fractional indexing submodule
FI_SRC = $(FI_DIR)/fractional_indexing.c
# Combined for SQLite extension build
SRC_FILES = $(CORE_SRC) $(SQLITE_SRC)
SRC_FILES = $(CORE_SRC) $(SQLITE_SRC) $(FI_SRC)

TEST_SRC = $(wildcard $(TEST_DIR)/*.c)
TEST_FILES = $(SRC_FILES) $(TEST_SRC) $(wildcard $(SQLITE_DIR)/*.c)
RELEASE_OBJ = $(patsubst %.c, $(BUILD_RELEASE)/%.o, $(notdir $(SRC_FILES)))
TEST_OBJ = $(patsubst %.c, $(BUILD_TEST)/%.o, $(notdir $(TEST_FILES)))
COV_FILES = $(filter-out $(SRC_DIR)/lz4.c $(SRC_DIR)/network.c $(SQLITE_IMPL_DIR)/sql_sqlite.c $(POSTGRES_IMPL_DIR)/database_postgresql.c, $(SRC_FILES))
COV_FILES = $(filter-out $(SRC_DIR)/lz4.c $(NETWORK_DIR)/network.c $(SQLITE_IMPL_DIR)/sql_sqlite.c $(POSTGRES_IMPL_DIR)/database_postgresql.c $(FI_SRC), $(SRC_FILES))
CURL_LIB = $(CURL_DIR)/$(PLATFORM)/libcurl.a
TEST_TARGET = $(patsubst %.c,$(DIST_DIR)/%$(EXE), $(notdir $(TEST_SRC)))

Expand Down Expand Up @@ -128,7 +132,7 @@ else ifeq ($(PLATFORM),android)
CURL_CONFIG = --host $(ARCH)-linux-$(ANDROID_ABI) --with-openssl=$(CURDIR)/$(OPENSSL_INSTALL_DIR) LDFLAGS="-L$(CURDIR)/$(OPENSSL_INSTALL_DIR)/lib" LIBS="-lssl -lcrypto" AR=$(BIN)/llvm-ar AS=$(BIN)/llvm-as CC=$(CC) CXX=$(BIN)/$(ARCH)-linux-$(ANDROID_ABI)-clang++ LD=$(BIN)/ld RANLIB=$(BIN)/llvm-ranlib STRIP=$(BIN)/llvm-strip
TARGET := $(DIST_DIR)/cloudsync.so
CFLAGS += -fPIC -I$(OPENSSL_INSTALL_DIR)/include
LDFLAGS += -shared -fPIC -L$(OPENSSL_INSTALL_DIR)/lib -lssl -lcrypto
LDFLAGS += -shared -fPIC -L$(OPENSSL_INSTALL_DIR)/lib -lssl -lcrypto -lm
STRIP = $(BIN)/llvm-strip --strip-unneeded $@
else ifeq ($(PLATFORM),ios)
TARGET := $(DIST_DIR)/cloudsync.dylib
Expand All @@ -148,8 +152,8 @@ else ifeq ($(PLATFORM),ios-sim)
STRIP = strip -x -S $@
else # linux
TARGET := $(DIST_DIR)/cloudsync.so
LDFLAGS += -shared -lssl -lcrypto
T_LDFLAGS += -lpthread
LDFLAGS += -shared -lssl -lcrypto -lm
T_LDFLAGS += -lpthread -lm
CURL_CONFIG = --with-openssl
STRIP = strip --strip-unneeded $@
endif
Expand All @@ -164,7 +168,7 @@ endif

# Native network support only for Apple platforms
ifdef NATIVE_NETWORK
RELEASE_OBJ += $(patsubst %.m, $(BUILD_RELEASE)/%_m.o, $(notdir $(wildcard $(SRC_DIR)/*.m)))
RELEASE_OBJ += $(patsubst %.m, $(BUILD_RELEASE)/%_m.o, $(notdir $(wildcard $(NETWORK_DIR)/*.m)))
LDFLAGS += -framework Foundation
CFLAGS += -DCLOUDSYNC_OMIT_CURL

Expand Down
135 changes: 134 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ In simple terms, CRDTs make it possible for multiple users to **edit shared data
- [Key Features](#key-features)
- [Built-in Network Layer](#built-in-network-layer)
- [Row-Level Security](#row-level-security)
- [Block-Level LWW](#block-level-lww)
- [What Can You Build with SQLite Sync?](#what-can-you-build-with-sqlite-sync)
- [Documentation](#documentation)
- [Installation](#installation)
- [Getting Started](#getting-started)
- [Block-Level LWW Example](#block-level-lww-example)
- [Database Schema Recommendations](#database-schema-recommendations)
- [Primary Key Requirements](#primary-key-requirements)
- [Column Constraint Guidelines](#column-constraint-guidelines)
Expand All @@ -32,6 +34,7 @@ In simple terms, CRDTs make it possible for multiple users to **edit shared data

- **Offline-First by Design**: Works seamlessly even when devices are offline. Changes are queued locally and synced automatically when connectivity is restored.
- **CRDT-Based Conflict Resolution**: Merges updates deterministically and efficiently, ensuring eventual consistency across all replicas without the need for complex merge logic.
- **Block-Level LWW for Text**: Fine-grained conflict resolution for text columns. Instead of overwriting the entire cell, changes are tracked and merged at the line (or paragraph) level, so concurrent edits to different parts of the same text are preserved.
- **Embedded Network Layer**: No external libraries or sync servers required. SQLiteSync handles connection setup, message encoding, retries, and state reconciliation internally.
- **Drop-in Simplicity**: Just load the extension into SQLite and start syncing. No need to implement custom protocols or state machines.
- **Efficient and Resilient**: Optimized binary encoding, automatic batching, and robust retry logic make synchronization fast and reliable even on flaky networks.
Expand Down Expand Up @@ -69,6 +72,30 @@ For example:

For more information, see the [SQLite Cloud RLS documentation](https://docs.sqlitecloud.io/docs/rls).

## Block-Level LWW

Standard CRDT sync resolves conflicts at the **cell level**: if two devices edit the same column of the same row, one value wins entirely. This works well for short values like names or statuses, but for longer text content — documents, notes, descriptions — it means the entire text is replaced even if the edits were in different parts.

**Block-Level LWW** (Last-Writer-Wins) solves this by splitting text columns into **blocks** (lines by default) and tracking each block independently. When two devices edit different lines of the same text, **both edits are preserved** after sync. Only when two devices edit the *same* line does LWW conflict resolution apply.

### How It Works

1. **Enable block tracking** on a text column using `cloudsync_set_column()`.
2. On INSERT or UPDATE, SQLite Sync automatically splits the text into blocks using the configured delimiter (default: newline `\n`).
3. Each block gets a unique fractional index position, enabling insertions between existing blocks without reindexing.
4. During sync, changes are merged block-by-block rather than replacing the whole cell.
5. Use `cloudsync_text_materialize()` to reconstruct the full text from blocks on demand, or read the column directly (it is updated automatically after merge).

### Key Properties

- **Non-conflicting edits are preserved**: Two users editing different lines of the same document both see their changes after sync.
- **Same-line conflicts use LWW**: If two users edit the same line, the last writer wins — consistent with standard CRDT behavior.
- **Custom delimiters**: Use paragraph separators (`\n\n`), sentence boundaries, or any string as the block delimiter.
- **Mixed columns**: A table can have both regular LWW columns and block-level LWW columns side by side.
- **Transparent reads**: The base column always contains the current full text. Block tracking is an internal mechanism; your queries work unchanged.

For setup instructions and a complete example, see [Block-Level LWW Example](#block-level-lww-example). For API details, see the [API Reference](./API.md).

### What Can You Build with SQLite Sync?

SQLite Sync is ideal for building collaborative and distributed apps across web, mobile, desktop, and edge platforms. Some example use cases include:
Expand Down Expand Up @@ -108,6 +135,7 @@ SQLite Sync is ideal for building collaborative and distributed apps across web,
For detailed information on all available functions, their parameters, and examples, refer to the [comprehensive API Reference](./API.md). The API includes:

- **Configuration Functions** — initialize, enable, and disable sync on tables
- **Block-Level LWW Functions** — configure block tracking on text columns and materialize text from blocks
- **Helper Functions** — version info, site IDs, UUID generation
- **Schema Alteration Functions** — safely alter synced tables
- **Network Functions** — connect, authenticate, send/receive changes, and monitor sync status
Expand Down Expand Up @@ -352,10 +380,115 @@ SELECT cloudsync_terminate();

See the [examples](./examples/simple-todo-db/) directory for a comprehensive walkthrough including:
- Multi-device collaboration
- Offline scenarios
- Offline scenarios
- Row-level security setup
- Conflict resolution demonstrations

## Block-Level LWW Example

This example shows how to enable block-level text sync on a notes table, so that concurrent edits to different lines are merged instead of overwritten.

### Setup

```sql
-- Load the extension
.load ./cloudsync

-- Create a table with a text column for long-form content
CREATE TABLE notes (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT ''
);

-- Initialize sync on the table
SELECT cloudsync_init('notes');

-- Enable block-level LWW on the "body" column
SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');
```

After this setup, every INSERT or UPDATE to the `body` column automatically splits the text into blocks (one per line) and tracks each block independently.

### Two-Device Scenario

```sql
-- Device A: create a note
INSERT INTO notes (id, title, body) VALUES (
'note-001',
'Meeting Notes',
'Line 1: Welcome
Line 2: Agenda
Line 3: Action items'
);

-- Sync Device A -> Cloud -> Device B
-- (Both devices now have the same 3-line note)
```

```sql
-- Device A (offline): edit line 1
UPDATE notes SET body = 'Line 1: Welcome everyone
Line 2: Agenda
Line 3: Action items' WHERE id = 'note-001';

-- Device B (offline): edit line 3
UPDATE notes SET body = 'Line 1: Welcome
Line 2: Agenda
Line 3: Action items - DONE' WHERE id = 'note-001';
```

```sql
-- After both devices sync, the merged result is:
-- 'Line 1: Welcome everyone
-- Line 2: Agenda
-- Line 3: Action items - DONE'
--
-- Both edits are preserved because they affected different lines.
```

### Custom Delimiter

For paragraph-level tracking (useful for long-form documents), set a custom delimiter:

```sql
-- Use double newline as delimiter (paragraph separator)
SELECT cloudsync_set_column('notes', 'body', 'delimiter', '

');
```

### Materializing Text

After a merge, the `body` column contains the reconstructed text automatically. You can also manually trigger materialization:

```sql
-- Reconstruct body from blocks for a specific row
SELECT cloudsync_text_materialize('notes', 'body', 'note-001');

-- Then read normally
SELECT body FROM notes WHERE id = 'note-001';
```

### Mixed Columns

Block-level LWW can be enabled on specific columns while other columns use standard cell-level LWW:

```sql
CREATE TABLE docs (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL DEFAULT '', -- standard LWW (cell-level)
body TEXT NOT NULL DEFAULT '', -- block LWW (line-level)
status TEXT NOT NULL DEFAULT '' -- standard LWW (cell-level)
);

SELECT cloudsync_init('docs');
SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');

-- Now: concurrent edits to "title" or "status" use normal LWW,
-- while concurrent edits to "body" merge at the line level.
```

## 📦 Integrations

Use SQLite-AI alongside:
Expand Down
6 changes: 4 additions & 2 deletions docker/Makefile.postgresql
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ PG_CORE_SRC = \
src/dbutils.c \
src/pk.c \
src/utils.c \
src/lz4.c
src/lz4.c \
src/block.c \
modules/fractional-indexing/fractional_indexing.c

# PostgreSQL-specific implementation
PG_IMPL_SRC = \
Expand All @@ -35,7 +37,7 @@ PG_OBJS = $(PG_ALL_SRC:.c=.o)

# Compiler flags
# Define POSIX macros as compiler flags to ensure they're defined before any includes
PG_CPPFLAGS = -I$(PG_INCLUDEDIR) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE
PG_CPPFLAGS = -I$(PG_INCLUDEDIR) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE
PG_CFLAGS = -fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -O2
PG_DEBUG ?= 0
ifeq ($(PG_DEBUG),1)
Expand Down
1 change: 1 addition & 0 deletions docker/postgresql/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ WORKDIR /tmp/cloudsync

# Copy entire source tree (needed for includes and makefiles)
COPY src/ ./src/
COPY modules/ ./modules/
COPY docker/ ./docker/
COPY Makefile .

Expand Down
Loading
Loading