Skip to content

Commit 8ad4ce2

Browse files
authored
feat: add block-level LWW for fine-grained text conflict resolution (#16)
* feat: add block-level LWW for fine-grained text conflict resolution Implements block-level Last-Writer-Wins for text columns across SQLite and PostgreSQL. Text is split into blocks (lines by default) and each block is tracked independently, so concurrent edits to different parts of the same text are preserved after sync. - Add block.c/block.h with split, diff, position, and materialize logic - Add fractional-indexing submodule for stable block ordering - Add cloudsync_set_column() and cloudsync_text_materialize() functions - Add cross-platform SQL abstractions for blocks table (SQLite/PostgreSQL) - Add block handling to PG insert, update, col_value, and set_column - Move network code to src/network/ directory - Bump version to 0.9.200 - Add 36 SQLite block LWW unit tests and 7 PostgreSQL test files - Update README and API docs with block-level LWW documentation * fix(ci): checkout submodules in GitHub Actions workflow
1 parent ec4a915 commit 8ad4ce2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+7599
-74
lines changed

.github/workflows/main.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,13 @@ jobs:
8383

8484
steps:
8585

86+
- name: install git for alpine container
87+
if: matrix.container
88+
run: apk add --no-cache git
89+
8690
- uses: actions/checkout@v4.2.2
91+
with:
92+
submodules: true
8793

8894
- name: android setup java
8995
if: matrix.name == 'android-aar'
@@ -234,6 +240,8 @@ jobs:
234240
steps:
235241

236242
- uses: actions/checkout@v4.2.2
243+
with:
244+
submodules: true
237245

238246
- name: build and start postgresql container
239247
run: make postgres-docker-rebuild

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "modules/fractional-indexing"]
2+
path = modules/fractional-indexing
3+
url = https://github.com/sqliteai/fractional-indexing

API.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ This document provides a reference for the SQLite functions provided by the `sql
1111
- [`cloudsync_is_enabled()`](#cloudsync_is_enabledtable_name)
1212
- [`cloudsync_cleanup()`](#cloudsync_cleanuptable_name)
1313
- [`cloudsync_terminate()`](#cloudsync_terminate)
14+
- [Block-Level LWW Functions](#block-level-lww-functions)
15+
- [`cloudsync_set_column()`](#cloudsync_set_columntable_name-col_name-key-value)
16+
- [`cloudsync_text_materialize()`](#cloudsync_text_materializetable_name-col_name-pk_values)
1417
- [Helper Functions](#helper-functions)
1518
- [`cloudsync_version()`](#cloudsync_version)
1619
- [`cloudsync_siteid()`](#cloudsync_siteid)
@@ -173,6 +176,68 @@ SELECT cloudsync_terminate();
173176

174177
---
175178

179+
## Block-Level LWW Functions
180+
181+
### `cloudsync_set_column(table_name, col_name, key, value)`
182+
183+
**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.
184+
185+
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.
186+
187+
**Parameters:**
188+
189+
- `table_name` (TEXT): The name of the synchronized table.
190+
- `col_name` (TEXT): The name of the text column to configure.
191+
- `key` (TEXT): The setting key. Supported keys:
192+
- `'algo'` — Set the column algorithm. Use value `'block'` to enable block-level LWW.
193+
- `'delimiter'` — Set the block delimiter string. Only applies to columns with block-level LWW enabled.
194+
- `value` (TEXT): The setting value.
195+
196+
**Returns:** None.
197+
198+
**Example:**
199+
200+
```sql
201+
-- Enable block-level LWW on a column (splits text by newline by default)
202+
SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');
203+
204+
-- Set a custom delimiter (e.g., double newline for paragraph-level tracking)
205+
SELECT cloudsync_set_column('notes', 'body', 'delimiter', '
206+
207+
');
208+
```
209+
210+
---
211+
212+
### `cloudsync_text_materialize(table_name, col_name, pk_values...)`
213+
214+
**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.
215+
216+
After a sync/merge, the column is updated automatically. This function is primarily useful for manual materialization or debugging.
217+
218+
**Parameters:**
219+
220+
- `table_name` (TEXT): The name of the table.
221+
- `col_name` (TEXT): The name of the block-level LWW column.
222+
- `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.
223+
224+
**Returns:** `1` on success.
225+
226+
**Example:**
227+
228+
```sql
229+
-- Materialize the body column for a specific row
230+
SELECT cloudsync_text_materialize('notes', 'body', 'note-001');
231+
232+
-- With a composite primary key (e.g., PRIMARY KEY (tenant_id, doc_id))
233+
SELECT cloudsync_text_materialize('docs', 'body', 'tenant-1', 'doc-001');
234+
235+
-- Read the materialized text
236+
SELECT body FROM notes WHERE id = 'note-001';
237+
```
238+
239+
---
240+
176241
## Helper Functions
177242

178243
### `cloudsync_version()`

Makefile

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ MAKEFLAGS += -j$(CPUS)
3232

3333
# Compiler and flags
3434
CC = gcc
35-
CFLAGS = -Wall -Wextra -Wno-unused-parameter -I$(SRC_DIR) -I$(SRC_DIR)/sqlite -I$(SRC_DIR)/postgresql -I$(SQLITE_DIR) -I$(CURL_DIR)/include
35+
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
3636
T_CFLAGS = $(CFLAGS) -DSQLITE_CORE -DCLOUDSYNC_UNITTEST -DCLOUDSYNC_OMIT_NETWORK -DCLOUDSYNC_OMIT_PRINT_RESULT
3737
COVERAGE = false
3838
ifndef NATIVE_NETWORK
@@ -46,7 +46,9 @@ POSTGRES_IMPL_DIR = $(SRC_DIR)/postgresql
4646
DIST_DIR = dist
4747
TEST_DIR = test
4848
SQLITE_DIR = sqlite
49-
VPATH = $(SRC_DIR):$(SQLITE_IMPL_DIR):$(POSTGRES_IMPL_DIR):$(SQLITE_DIR):$(TEST_DIR)
49+
FI_DIR = modules/fractional-indexing
50+
NETWORK_DIR = $(SRC_DIR)/network
51+
VPATH = $(SRC_DIR):$(SQLITE_IMPL_DIR):$(POSTGRES_IMPL_DIR):$(NETWORK_DIR):$(SQLITE_DIR):$(TEST_DIR):$(FI_DIR)
5052
BUILD_RELEASE = build/release
5153
BUILD_TEST = build/test
5254
BUILD_DIRS = $(BUILD_TEST) $(BUILD_RELEASE)
@@ -62,17 +64,19 @@ ifeq ($(PLATFORM),android)
6264
endif
6365

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

7175
TEST_SRC = $(wildcard $(TEST_DIR)/*.c)
7276
TEST_FILES = $(SRC_FILES) $(TEST_SRC) $(wildcard $(SQLITE_DIR)/*.c)
7377
RELEASE_OBJ = $(patsubst %.c, $(BUILD_RELEASE)/%.o, $(notdir $(SRC_FILES)))
7478
TEST_OBJ = $(patsubst %.c, $(BUILD_TEST)/%.o, $(notdir $(TEST_FILES)))
75-
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))
79+
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))
7680
CURL_LIB = $(CURL_DIR)/$(PLATFORM)/libcurl.a
7781
TEST_TARGET = $(patsubst %.c,$(DIST_DIR)/%$(EXE), $(notdir $(TEST_SRC)))
7882

@@ -128,7 +132,7 @@ else ifeq ($(PLATFORM),android)
128132
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
129133
TARGET := $(DIST_DIR)/cloudsync.so
130134
CFLAGS += -fPIC -I$(OPENSSL_INSTALL_DIR)/include
131-
LDFLAGS += -shared -fPIC -L$(OPENSSL_INSTALL_DIR)/lib -lssl -lcrypto
135+
LDFLAGS += -shared -fPIC -L$(OPENSSL_INSTALL_DIR)/lib -lssl -lcrypto -lm
132136
STRIP = $(BIN)/llvm-strip --strip-unneeded $@
133137
else ifeq ($(PLATFORM),ios)
134138
TARGET := $(DIST_DIR)/cloudsync.dylib
@@ -148,8 +152,8 @@ else ifeq ($(PLATFORM),ios-sim)
148152
STRIP = strip -x -S $@
149153
else # linux
150154
TARGET := $(DIST_DIR)/cloudsync.so
151-
LDFLAGS += -shared -lssl -lcrypto
152-
T_LDFLAGS += -lpthread
155+
LDFLAGS += -shared -lssl -lcrypto -lm
156+
T_LDFLAGS += -lpthread -lm
153157
CURL_CONFIG = --with-openssl
154158
STRIP = strip --strip-unneeded $@
155159
endif
@@ -164,7 +168,7 @@ endif
164168

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

README.md

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ In simple terms, CRDTs make it possible for multiple users to **edit shared data
1616
- [Key Features](#key-features)
1717
- [Built-in Network Layer](#built-in-network-layer)
1818
- [Row-Level Security](#row-level-security)
19+
- [Block-Level LWW](#block-level-lww)
1920
- [What Can You Build with SQLite Sync?](#what-can-you-build-with-sqlite-sync)
2021
- [Documentation](#documentation)
2122
- [Installation](#installation)
2223
- [Getting Started](#getting-started)
24+
- [Block-Level LWW Example](#block-level-lww-example)
2325
- [Database Schema Recommendations](#database-schema-recommendations)
2426
- [Primary Key Requirements](#primary-key-requirements)
2527
- [Column Constraint Guidelines](#column-constraint-guidelines)
@@ -32,6 +34,7 @@ In simple terms, CRDTs make it possible for multiple users to **edit shared data
3234

3335
- **Offline-First by Design**: Works seamlessly even when devices are offline. Changes are queued locally and synced automatically when connectivity is restored.
3436
- **CRDT-Based Conflict Resolution**: Merges updates deterministically and efficiently, ensuring eventual consistency across all replicas without the need for complex merge logic.
37+
- **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.
3538
- **Embedded Network Layer**: No external libraries or sync servers required. SQLiteSync handles connection setup, message encoding, retries, and state reconciliation internally.
3639
- **Drop-in Simplicity**: Just load the extension into SQLite and start syncing. No need to implement custom protocols or state machines.
3740
- **Efficient and Resilient**: Optimized binary encoding, automatic batching, and robust retry logic make synchronization fast and reliable even on flaky networks.
@@ -69,6 +72,30 @@ For example:
6972

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

75+
## Block-Level LWW
76+
77+
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.
78+
79+
**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.
80+
81+
### How It Works
82+
83+
1. **Enable block tracking** on a text column using `cloudsync_set_column()`.
84+
2. On INSERT or UPDATE, SQLite Sync automatically splits the text into blocks using the configured delimiter (default: newline `\n`).
85+
3. Each block gets a unique fractional index position, enabling insertions between existing blocks without reindexing.
86+
4. During sync, changes are merged block-by-block rather than replacing the whole cell.
87+
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).
88+
89+
### Key Properties
90+
91+
- **Non-conflicting edits are preserved**: Two users editing different lines of the same document both see their changes after sync.
92+
- **Same-line conflicts use LWW**: If two users edit the same line, the last writer wins — consistent with standard CRDT behavior.
93+
- **Custom delimiters**: Use paragraph separators (`\n\n`), sentence boundaries, or any string as the block delimiter.
94+
- **Mixed columns**: A table can have both regular LWW columns and block-level LWW columns side by side.
95+
- **Transparent reads**: The base column always contains the current full text. Block tracking is an internal mechanism; your queries work unchanged.
96+
97+
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).
98+
7299
### What Can You Build with SQLite Sync?
73100

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

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

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

387+
## Block-Level LWW Example
388+
389+
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.
390+
391+
### Setup
392+
393+
```sql
394+
-- Load the extension
395+
.load ./cloudsync
396+
397+
-- Create a table with a text column for long-form content
398+
CREATE TABLE notes (
399+
id TEXT PRIMARY KEY NOT NULL,
400+
title TEXT NOT NULL DEFAULT '',
401+
body TEXT NOT NULL DEFAULT ''
402+
);
403+
404+
-- Initialize sync on the table
405+
SELECT cloudsync_init('notes');
406+
407+
-- Enable block-level LWW on the "body" column
408+
SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');
409+
```
410+
411+
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.
412+
413+
### Two-Device Scenario
414+
415+
```sql
416+
-- Device A: create a note
417+
INSERT INTO notes (id, title, body) VALUES (
418+
'note-001',
419+
'Meeting Notes',
420+
'Line 1: Welcome
421+
Line 2: Agenda
422+
Line 3: Action items'
423+
);
424+
425+
-- Sync Device A -> Cloud -> Device B
426+
-- (Both devices now have the same 3-line note)
427+
```
428+
429+
```sql
430+
-- Device A (offline): edit line 1
431+
UPDATE notes SET body = 'Line 1: Welcome everyone
432+
Line 2: Agenda
433+
Line 3: Action items' WHERE id = 'note-001';
434+
435+
-- Device B (offline): edit line 3
436+
UPDATE notes SET body = 'Line 1: Welcome
437+
Line 2: Agenda
438+
Line 3: Action items - DONE' WHERE id = 'note-001';
439+
```
440+
441+
```sql
442+
-- After both devices sync, the merged result is:
443+
-- 'Line 1: Welcome everyone
444+
-- Line 2: Agenda
445+
-- Line 3: Action items - DONE'
446+
--
447+
-- Both edits are preserved because they affected different lines.
448+
```
449+
450+
### Custom Delimiter
451+
452+
For paragraph-level tracking (useful for long-form documents), set a custom delimiter:
453+
454+
```sql
455+
-- Use double newline as delimiter (paragraph separator)
456+
SELECT cloudsync_set_column('notes', 'body', 'delimiter', '
457+
458+
');
459+
```
460+
461+
### Materializing Text
462+
463+
After a merge, the `body` column contains the reconstructed text automatically. You can also manually trigger materialization:
464+
465+
```sql
466+
-- Reconstruct body from blocks for a specific row
467+
SELECT cloudsync_text_materialize('notes', 'body', 'note-001');
468+
469+
-- Then read normally
470+
SELECT body FROM notes WHERE id = 'note-001';
471+
```
472+
473+
### Mixed Columns
474+
475+
Block-level LWW can be enabled on specific columns while other columns use standard cell-level LWW:
476+
477+
```sql
478+
CREATE TABLE docs (
479+
id TEXT PRIMARY KEY NOT NULL,
480+
title TEXT NOT NULL DEFAULT '', -- standard LWW (cell-level)
481+
body TEXT NOT NULL DEFAULT '', -- block LWW (line-level)
482+
status TEXT NOT NULL DEFAULT '' -- standard LWW (cell-level)
483+
);
484+
485+
SELECT cloudsync_init('docs');
486+
SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');
487+
488+
-- Now: concurrent edits to "title" or "status" use normal LWW,
489+
-- while concurrent edits to "body" merge at the line level.
490+
```
491+
359492
## 📦 Integrations
360493

361494
Use SQLite-AI alongside:

docker/Makefile.postgresql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ PG_CORE_SRC = \
2020
src/dbutils.c \
2121
src/pk.c \
2222
src/utils.c \
23-
src/lz4.c
23+
src/lz4.c \
24+
src/block.c \
25+
modules/fractional-indexing/fractional_indexing.c
2426

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

3638
# Compiler flags
3739
# Define POSIX macros as compiler flags to ensure they're defined before any includes
38-
PG_CPPFLAGS = -I$(PG_INCLUDEDIR) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE
40+
PG_CPPFLAGS = -I$(PG_INCLUDEDIR) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE
3941
PG_CFLAGS = -fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -O2
4042
PG_DEBUG ?= 0
4143
ifeq ($(PG_DEBUG),1)

docker/postgresql/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ WORKDIR /tmp/cloudsync
1414

1515
# Copy entire source tree (needed for includes and makefiles)
1616
COPY src/ ./src/
17+
COPY modules/ ./modules/
1718
COPY docker/ ./docker/
1819
COPY Makefile .
1920

0 commit comments

Comments
 (0)