diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee56489..65e655a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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' @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7e48716 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "modules/fractional-indexing"] + path = modules/fractional-indexing + url = https://github.com/sqliteai/fractional-indexing diff --git a/API.md b/API.md index a307df5..00441c4 100644 --- a/API.md +++ b/API.md @@ -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) @@ -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()` diff --git a/Makefile b/Makefile index ae3423f..74d6c6f 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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) @@ -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))) @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 0d0f399..b649fd3 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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: @@ -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 @@ -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: diff --git a/docker/Makefile.postgresql b/docker/Makefile.postgresql index 78ae6bf..70b3da9 100644 --- a/docker/Makefile.postgresql +++ b/docker/Makefile.postgresql @@ -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 = \ @@ -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) diff --git a/docker/postgresql/Dockerfile b/docker/postgresql/Dockerfile index 536b963..ec3d30c 100644 --- a/docker/postgresql/Dockerfile +++ b/docker/postgresql/Dockerfile @@ -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 . diff --git a/docker/postgresql/Dockerfile.debug b/docker/postgresql/Dockerfile.debug index caf1091..3f77c04 100644 --- a/docker/postgresql/Dockerfile.debug +++ b/docker/postgresql/Dockerfile.debug @@ -51,6 +51,7 @@ ENV LD_LIBRARY_PATH="/usr/local/pgsql/lib:${LD_LIBRARY_PATH}" # Copy entire source tree (needed for includes and makefiles) COPY src/ ./src/ +COPY modules/ ./modules/ COPY docker/ ./docker/ COPY Makefile . @@ -65,11 +66,11 @@ RUN set -eux; \ make postgres-build PG_DEBUG=1 \ PG_CFLAGS="-fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -g -O0 -fno-omit-frame-pointer ${ASAN_CFLAGS}" \ PG_LDFLAGS="-shared ${ASAN_LDFLAGS}" \ - PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ + PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ make postgres-install PG_DEBUG=1 \ PG_CFLAGS="-fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -g -O0 -fno-omit-frame-pointer ${ASAN_CFLAGS}" \ PG_LDFLAGS="-shared ${ASAN_LDFLAGS}" \ - PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ + PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ make postgres-clean # Verify installation diff --git a/docker/postgresql/Dockerfile.debug-no-optimization b/docker/postgresql/Dockerfile.debug-no-optimization index caf1091..3f77c04 100644 --- a/docker/postgresql/Dockerfile.debug-no-optimization +++ b/docker/postgresql/Dockerfile.debug-no-optimization @@ -51,6 +51,7 @@ ENV LD_LIBRARY_PATH="/usr/local/pgsql/lib:${LD_LIBRARY_PATH}" # Copy entire source tree (needed for includes and makefiles) COPY src/ ./src/ +COPY modules/ ./modules/ COPY docker/ ./docker/ COPY Makefile . @@ -65,11 +66,11 @@ RUN set -eux; \ make postgres-build PG_DEBUG=1 \ PG_CFLAGS="-fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -g -O0 -fno-omit-frame-pointer ${ASAN_CFLAGS}" \ PG_LDFLAGS="-shared ${ASAN_LDFLAGS}" \ - PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ + PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ make postgres-install PG_DEBUG=1 \ PG_CFLAGS="-fPIC -Wall -Wextra -Wno-unused-parameter -std=c11 -g -O0 -fno-omit-frame-pointer ${ASAN_CFLAGS}" \ PG_LDFLAGS="-shared ${ASAN_LDFLAGS}" \ - PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ + PG_CPPFLAGS="-I$(pg_config --includedir-server) -Isrc -Isrc/postgresql -Imodules/fractional-indexing -DCLOUDSYNC_POSTGRESQL_BUILD -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE" && \ make postgres-clean # Verify installation diff --git a/docker/postgresql/Dockerfile.supabase b/docker/postgresql/Dockerfile.supabase index a609f68..0b5cd10 100644 --- a/docker/postgresql/Dockerfile.supabase +++ b/docker/postgresql/Dockerfile.supabase @@ -15,6 +15,7 @@ WORKDIR /tmp/cloudsync # Copy entire source tree (needed for includes and makefiles) COPY src/ ./src/ +COPY modules/ ./modules/ COPY docker/ ./docker/ COPY Makefile . diff --git a/modules/fractional-indexing b/modules/fractional-indexing new file mode 160000 index 0000000..b9af0ec --- /dev/null +++ b/modules/fractional-indexing @@ -0,0 +1 @@ +Subproject commit b9af0ec5b818bca29919e1a8d42b142feb71f269 diff --git a/src/block.c b/src/block.c new file mode 100644 index 0000000..ce252b4 --- /dev/null +++ b/src/block.c @@ -0,0 +1,297 @@ +// +// block.c +// cloudsync +// +// Block-level LWW CRDT support for text/blob fields. +// + +#include +#include +#include +#include "block.h" +#include "utils.h" +#include "fractional_indexing.h" + +// MARK: - Col name helpers - + +bool block_is_block_colname(const char *col_name) { + if (!col_name) return false; + return strchr(col_name, BLOCK_SEPARATOR) != NULL; +} + +char *block_extract_base_colname(const char *col_name) { + if (!col_name) return NULL; + const char *sep = strchr(col_name, BLOCK_SEPARATOR); + if (!sep) return cloudsync_string_dup(col_name); + return cloudsync_string_ndup(col_name, (size_t)(sep - col_name)); +} + +const char *block_extract_position_id(const char *col_name) { + if (!col_name) return NULL; + const char *sep = strchr(col_name, BLOCK_SEPARATOR); + if (!sep) return NULL; + return sep + 1; +} + +char *block_build_colname(const char *base_col, const char *position_id) { + if (!base_col || !position_id) return NULL; + size_t blen = strlen(base_col); + size_t plen = strlen(position_id); + char *result = (char *)cloudsync_memory_alloc(blen + 1 + plen + 1); + if (!result) return NULL; + memcpy(result, base_col, blen); + result[blen] = BLOCK_SEPARATOR; + memcpy(result + blen + 1, position_id, plen); + result[blen + 1 + plen] = '\0'; + return result; +} + +// MARK: - Text splitting - + +static block_list_t *block_list_create(void) { + block_list_t *list = (block_list_t *)cloudsync_memory_zeroalloc(sizeof(block_list_t)); + return list; +} + +static bool block_list_append(block_list_t *list, const char *content, size_t content_len, const char *position_id) { + if (list->count >= list->capacity) { + int new_cap = list->capacity ? list->capacity * 2 : 16; + block_entry_t *new_entries = (block_entry_t *)cloudsync_memory_realloc( + list->entries, (uint64_t)(new_cap * sizeof(block_entry_t))); + if (!new_entries) return false; + list->entries = new_entries; + list->capacity = new_cap; + } + block_entry_t *e = &list->entries[list->count]; + e->content = cloudsync_string_ndup(content, content_len); + e->position_id = position_id ? cloudsync_string_dup(position_id) : NULL; + if (!e->content) return false; + list->count++; + return true; +} + +void block_list_free(block_list_t *list) { + if (!list) return; + for (int i = 0; i < list->count; i++) { + if (list->entries[i].content) cloudsync_memory_free(list->entries[i].content); + if (list->entries[i].position_id) cloudsync_memory_free(list->entries[i].position_id); + } + if (list->entries) cloudsync_memory_free(list->entries); + cloudsync_memory_free(list); +} + +block_list_t *block_list_create_empty(void) { + return block_list_create(); +} + +bool block_list_add(block_list_t *list, const char *content, const char *position_id) { + if (!list) return false; + return block_list_append(list, content, strlen(content), position_id); +} + +block_list_t *block_split(const char *text, const char *delimiter) { + block_list_t *list = block_list_create(); + if (!list) return NULL; + + if (!text || !*text) { + // Empty text produces a single empty block + block_list_append(list, "", 0, NULL); + return list; + } + + size_t dlen = strlen(delimiter); + if (dlen == 0) { + // No delimiter: entire text is one block + block_list_append(list, text, strlen(text), NULL); + return list; + } + + const char *start = text; + const char *found; + while ((found = strstr(start, delimiter)) != NULL) { + size_t seg_len = (size_t)(found - start); + if (!block_list_append(list, start, seg_len, NULL)) { + block_list_free(list); + return NULL; + } + start = found + dlen; + } + // Last segment (after last delimiter or entire string if no delimiter found) + if (!block_list_append(list, start, strlen(start), NULL)) { + block_list_free(list); + return NULL; + } + + return list; +} + +// MARK: - Fractional indexing (via fractional-indexing submodule) - + +// Wrapper for calloc: fractional_indexing expects (count, size) but cloudsync_memory_zeroalloc takes a single size. +static void *fi_calloc_wrapper(size_t count, size_t size) { + return cloudsync_memory_zeroalloc((uint64_t)(count * size)); +} + +void block_init_allocator(void) { + fractional_indexing_allocator alloc = { + .malloc = (void *(*)(size_t))cloudsync_memory_alloc, + .calloc = fi_calloc_wrapper, + .free = cloudsync_memory_free + }; + fractional_indexing_set_allocator(&alloc); +} + +char *block_position_between(const char *before, const char *after) { + return generate_key_between(before, after); +} + +char **block_initial_positions(int count) { + if (count <= 0) return NULL; + return generate_n_keys_between(NULL, NULL, count); +} + +// MARK: - Block diff - + +static block_diff_t *block_diff_create(void) { + block_diff_t *diff = (block_diff_t *)cloudsync_memory_zeroalloc(sizeof(block_diff_t)); + return diff; +} + +static bool block_diff_append(block_diff_t *diff, block_diff_type type, const char *position_id, const char *content) { + if (diff->count >= diff->capacity) { + int new_cap = diff->capacity ? diff->capacity * 2 : 16; + block_diff_entry_t *new_entries = (block_diff_entry_t *)cloudsync_memory_realloc( + diff->entries, (uint64_t)(new_cap * sizeof(block_diff_entry_t))); + if (!new_entries) return false; + diff->entries = new_entries; + diff->capacity = new_cap; + } + block_diff_entry_t *e = &diff->entries[diff->count]; + e->type = type; + e->position_id = cloudsync_string_dup(position_id); + e->content = content ? cloudsync_string_dup(content) : NULL; + diff->count++; + return true; +} + +void block_diff_free(block_diff_t *diff) { + if (!diff) return; + for (int i = 0; i < diff->count; i++) { + if (diff->entries[i].position_id) cloudsync_memory_free(diff->entries[i].position_id); + if (diff->entries[i].content) cloudsync_memory_free(diff->entries[i].content); + } + if (diff->entries) cloudsync_memory_free(diff->entries); + cloudsync_memory_free(diff); +} + +// Content-based matching diff algorithm: +// 1. Build a consumed-set from old blocks +// 2. For each new block, find the first unconsumed old block with matching content +// 3. Matched blocks keep their position_id (UNCHANGED) +// 4. Unmatched new blocks get new position_ids (ADDED) +// 5. Unconsumed old blocks are REMOVED +// Modified blocks are detected when content changed but position stayed (handled as MODIFIED) +block_diff_t *block_diff(block_entry_t *old_blocks, int old_count, + const char **new_parts, int new_count) { + block_diff_t *diff = block_diff_create(); + if (!diff) return NULL; + + // Track which old blocks have been consumed + bool *old_consumed = NULL; + if (old_count > 0) { + old_consumed = (bool *)cloudsync_memory_zeroalloc((uint64_t)(old_count * sizeof(bool))); + if (!old_consumed) { + block_diff_free(diff); + return NULL; + } + } + + // For each new block, try to find a matching unconsumed old block + // Use a simple forward scan to preserve ordering + int old_scan = 0; + char *last_position = NULL; + + for (int ni = 0; ni < new_count; ni++) { + bool found = false; + + // Scan forward in old blocks for a content match + for (int oi = old_scan; oi < old_count; oi++) { + if (old_consumed[oi]) continue; + + if (strcmp(old_blocks[oi].content, new_parts[ni]) == 0) { + // Exact match — mark any skipped old blocks as REMOVED + for (int si = old_scan; si < oi; si++) { + if (!old_consumed[si]) { + block_diff_append(diff, BLOCK_DIFF_REMOVED, old_blocks[si].position_id, NULL); + old_consumed[si] = true; + } + } + old_consumed[oi] = true; + old_scan = oi + 1; + last_position = old_blocks[oi].position_id; + found = true; + break; + } + } + + if (!found) { + // New block — needs a new position_id + const char *next_pos = NULL; + // Find the next unconsumed old block's position for the upper bound + for (int oi = old_scan; oi < old_count; oi++) { + if (!old_consumed[oi]) { + next_pos = old_blocks[oi].position_id; + break; + } + } + + char *new_pos = block_position_between(last_position, next_pos); + if (new_pos) { + block_diff_append(diff, BLOCK_DIFF_ADDED, new_pos, new_parts[ni]); + last_position = diff->entries[diff->count - 1].position_id; + cloudsync_memory_free(new_pos); + } + } + } + + // Mark remaining unconsumed old blocks as REMOVED + for (int oi = old_scan; oi < old_count; oi++) { + if (!old_consumed[oi]) { + block_diff_append(diff, BLOCK_DIFF_REMOVED, old_blocks[oi].position_id, NULL); + } + } + + if (old_consumed) cloudsync_memory_free(old_consumed); + return diff; +} + +// MARK: - Materialization - + +char *block_materialize_text(const char **blocks, int count, const char *delimiter) { + if (count == 0) return cloudsync_string_dup(""); + if (!delimiter) delimiter = BLOCK_DEFAULT_DELIMITER; + + size_t dlen = strlen(delimiter); + size_t total = 0; + for (int i = 0; i < count; i++) { + total += strlen(blocks[i]); + if (i < count - 1) total += dlen; + } + + char *result = (char *)cloudsync_memory_alloc(total + 1); + if (!result) return NULL; + + size_t offset = 0; + for (int i = 0; i < count; i++) { + size_t blen = strlen(blocks[i]); + memcpy(result + offset, blocks[i], blen); + offset += blen; + if (i < count - 1) { + memcpy(result + offset, delimiter, dlen); + offset += dlen; + } + } + result[offset] = '\0'; + + return result; +} diff --git a/src/block.h b/src/block.h new file mode 100644 index 0000000..fa43369 --- /dev/null +++ b/src/block.h @@ -0,0 +1,120 @@ +// +// block.h +// cloudsync +// +// Block-level LWW CRDT support for text/blob fields. +// Instead of replacing an entire text column on conflict, +// the text is split into blocks (lines/paragraphs) that are +// independently version-tracked and merged. +// + +#ifndef __CLOUDSYNC_BLOCK__ +#define __CLOUDSYNC_BLOCK__ + +#include +#include +#include + +// The separator character used in col_name to distinguish block entries +// from regular column entries. Format: "col_name\x1Fposition_id" +#define BLOCK_SEPARATOR '\x1F' +#define BLOCK_SEPARATOR_STR "\x1F" +#define BLOCK_DEFAULT_DELIMITER "\n" + +// Column-level algorithm for block tracking +typedef enum { + col_algo_normal = 0, + col_algo_block = 1 +} col_algo_t; + +// A single block from splitting text +typedef struct { + char *content; // block text (owned, must be freed) + char *position_id; // fractional index position (owned, must be freed) +} block_entry_t; + +// Array of blocks +typedef struct { + block_entry_t *entries; + int count; + int capacity; +} block_list_t; + +// Diff result for comparing old and new block lists +typedef enum { + BLOCK_DIFF_UNCHANGED = 0, + BLOCK_DIFF_ADDED = 1, + BLOCK_DIFF_MODIFIED = 2, + BLOCK_DIFF_REMOVED = 3 +} block_diff_type; + +typedef struct { + block_diff_type type; + char *position_id; // the position_id (owned, must be freed) + char *content; // new content (owned, must be freed; NULL for REMOVED) +} block_diff_entry_t; + +typedef struct { + block_diff_entry_t *entries; + int count; + int capacity; +} block_diff_t; + +// Initialize the fractional-indexing library to use cloudsync's allocator. +// Must be called once before any block_position_between / block_initial_positions calls. +void block_init_allocator(void); + +// Check if a col_name is a block entry (contains BLOCK_SEPARATOR) +bool block_is_block_colname(const char *col_name); + +// Extract the base column name from a block col_name (caller must free) +// e.g., "body\x1F0.5" -> "body" +char *block_extract_base_colname(const char *col_name); + +// Extract the position_id from a block col_name +// e.g., "body\x1F0.5" -> "0.5" +const char *block_extract_position_id(const char *col_name); + +// Build a block col_name from base + position_id (caller must free) +// e.g., ("body", "0.5") -> "body\x1F0.5" +char *block_build_colname(const char *base_col, const char *position_id); + +// Split text into blocks using the given delimiter +block_list_t *block_split(const char *text, const char *delimiter); + +// Free a block list +void block_list_free(block_list_t *list); + +// Generate fractional index position IDs for N initial blocks +// Returns array of N strings (caller must free each + the array) +char **block_initial_positions(int count); + +// Generate a position ID that sorts between 'before' and 'after' +// Either can be NULL (meaning beginning/end of sequence) +// Caller must free the result +char *block_position_between(const char *before, const char *after); + +// Compute diff between old blocks (with position IDs) and new content blocks +// old_blocks: existing blocks from metadata (with position_ids) +// new_parts: new text split by delimiter (no position_ids yet) +// new_count: number of new parts +block_diff_t *block_diff(block_entry_t *old_blocks, int old_count, + const char **new_parts, int new_count); + +// Free a diff result +void block_diff_free(block_diff_t *diff); + +// Create an empty block list (for accumulating existing blocks) +block_list_t *block_list_create_empty(void); + +// Add a block entry to a list (content and position_id are copied) +bool block_list_add(block_list_t *list, const char *content, const char *position_id); + +// Concatenate block values with delimiter +// blocks: array of content strings (in position order) +// count: number of blocks +// delimiter: separator between blocks +// Returns allocated string (caller must free) +char *block_materialize_text(const char **blocks, int count, const char *delimiter); + +#endif diff --git a/src/cloudsync.c b/src/cloudsync.c index c3d3f09..b1bdbaa 100644 --- a/src/cloudsync.c +++ b/src/cloudsync.c @@ -22,6 +22,7 @@ #include "sql.h" #include "utils.h" #include "dbutils.h" +#include "block.h" #ifdef _WIN32 #include @@ -188,6 +189,14 @@ struct cloudsync_table_context { dbvm_t **col_merge_stmt; // array of merge insert stmt (indexed by col_name) dbvm_t **col_value_stmt; // array of column value stmt (indexed by col_name) int *col_id; // array of column id + col_algo_t *col_algo; // per-column algorithm (normal or block) + char **col_delimiter; // per-column delimiter for block splitting (NULL = default "\n") + bool has_block_cols; // quick check: does this table have any block columns? + dbvm_t *block_value_read_stmt; // SELECT col_value FROM blocks table + dbvm_t *block_value_write_stmt; // INSERT OR REPLACE into blocks table + dbvm_t *block_value_delete_stmt; // DELETE from blocks table + dbvm_t *block_list_stmt; // SELECT block entries for materialization + char *blocks_ref; // schema-qualified blocks table name int ncols; // number of non primary key cols int npks; // number of primary key cols bool enabled; // flag to check if a table is enabled or disabled @@ -731,8 +740,23 @@ void table_free (cloudsync_table_context *table) { if (table->col_id) { cloudsync_memory_free(table->col_id); } + if (table->col_algo) { + cloudsync_memory_free(table->col_algo); + } + if (table->col_delimiter) { + for (int i=0; incols; ++i) { + if (table->col_delimiter[i]) cloudsync_memory_free(table->col_delimiter[i]); + } + cloudsync_memory_free(table->col_delimiter); + } } - + + if (table->block_value_read_stmt) databasevm_finalize(table->block_value_read_stmt); + if (table->block_value_write_stmt) databasevm_finalize(table->block_value_write_stmt); + if (table->block_value_delete_stmt) databasevm_finalize(table->block_value_delete_stmt); + if (table->block_list_stmt) databasevm_finalize(table->block_list_stmt); + if (table->blocks_ref) cloudsync_memory_free(table->blocks_ref); + if (table->name) cloudsync_memory_free(table->name); if (table->schema) cloudsync_memory_free(table->schema); if (table->meta_ref) cloudsync_memory_free(table->meta_ref); @@ -1065,6 +1089,12 @@ bool table_add_to_context (cloudsync_context *data, table_algo algo, const char table->col_value_stmt = (dbvm_t **)cloudsync_memory_alloc((uint64_t)(sizeof(void *) * ncols)); if (!table->col_value_stmt) goto abort_add_table; + table->col_algo = (col_algo_t *)cloudsync_memory_zeroalloc((uint64_t)(sizeof(col_algo_t) * ncols)); + if (!table->col_algo) goto abort_add_table; + + table->col_delimiter = (char **)cloudsync_memory_zeroalloc((uint64_t)(sizeof(char *) * ncols)); + if (!table->col_delimiter) goto abort_add_table; + // Pass empty string when schema is NULL; SQL will fall back to current_schema() const char *schema = table->schema ? table->schema : ""; char *sql = cloudsync_memory_mprintf(SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID, @@ -1604,17 +1634,29 @@ int merge_did_cid_win (cloudsync_context *data, cloudsync_table_context *table, } // rc == DBRES_ROW and col_version == local_version, need to compare values - + // retrieve col_value precompiled statement - dbvm_t *vm = table_column_lookup(table, col_name, false, NULL); - if (!vm) return cloudsync_set_error(data, "Unable to retrieve column value precompiled statement in merge_did_cid_win", DBRES_ERROR); - - // bind primary key values - rc = pk_decode_prikey((char *)pk, (size_t)pklen, pk_decode_bind_callback, (void *)vm); - if (rc < 0) { - rc = cloudsync_set_dberror(data); - dbvm_reset(vm); - return rc; + bool is_block_col = block_is_block_colname(col_name) && table_has_block_cols(table); + dbvm_t *vm; + if (is_block_col) { + // Block column: read value from blocks table (pk + col_name bindings) + vm = table_block_value_read_stmt(table); + if (!vm) return cloudsync_set_error(data, "Unable to retrieve block value read statement in merge_did_cid_win", DBRES_ERROR); + rc = databasevm_bind_blob(vm, 1, (const void *)pk, pklen); + if (rc != DBRES_OK) { dbvm_reset(vm); return cloudsync_set_dberror(data); } + rc = databasevm_bind_text(vm, 2, col_name, -1); + if (rc != DBRES_OK) { dbvm_reset(vm); return cloudsync_set_dberror(data); } + } else { + vm = table_column_lookup(table, col_name, false, NULL); + if (!vm) return cloudsync_set_error(data, "Unable to retrieve column value precompiled statement in merge_did_cid_win", DBRES_ERROR); + + // bind primary key values + rc = pk_decode_prikey((char *)pk, (size_t)pklen, pk_decode_bind_callback, (void *)vm); + if (rc < 0) { + rc = cloudsync_set_dberror(data); + dbvm_reset(vm); + return rc; + } } // execute vm @@ -1720,6 +1762,195 @@ int merge_sentinel_only_insert (cloudsync_context *data, cloudsync_table_context return merge_set_winner_clock(data, table, pk, pklen, NULL, cl, db_version, site_id, site_len, seq, rowid); } +// MARK: - Block-level merge helpers - + +// Store a block value in the blocks table +static int block_store_value (cloudsync_context *data, cloudsync_table_context *table, const void *pk, int pklen, const char *block_colname, dbvalue_t *col_value) { + dbvm_t *vm = table->block_value_write_stmt; + if (!vm) return cloudsync_set_error(data, "block_store_value: blocks table not initialized", DBRES_MISUSE); + + int rc = databasevm_bind_blob(vm, 1, pk, pklen); + if (rc != DBRES_OK) goto cleanup; + rc = databasevm_bind_text(vm, 2, block_colname, -1); + if (rc != DBRES_OK) goto cleanup; + if (col_value) { + rc = databasevm_bind_value(vm, 3, col_value); + } else { + rc = databasevm_bind_null(vm, 3); + } + if (rc != DBRES_OK) goto cleanup; + + rc = databasevm_step(vm); + if (rc == DBRES_DONE) rc = DBRES_OK; + +cleanup: + if (rc != DBRES_OK) cloudsync_set_dberror(data); + databasevm_reset(vm); + return rc; +} + +// Delete a block value from the blocks table +static int block_delete_value (cloudsync_context *data, cloudsync_table_context *table, const void *pk, int pklen, const char *block_colname) { + dbvm_t *vm = table->block_value_delete_stmt; + if (!vm) return cloudsync_set_error(data, "block_delete_value: blocks table not initialized", DBRES_MISUSE); + + int rc = databasevm_bind_blob(vm, 1, pk, pklen); + if (rc != DBRES_OK) goto cleanup; + rc = databasevm_bind_text(vm, 2, block_colname, -1); + if (rc != DBRES_OK) goto cleanup; + + rc = databasevm_step(vm); + if (rc == DBRES_DONE) rc = DBRES_OK; + +cleanup: + if (rc != DBRES_OK) cloudsync_set_dberror(data); + databasevm_reset(vm); + return rc; +} + +// Materialize all alive blocks for a base column into the base table +int block_materialize_column (cloudsync_context *data, cloudsync_table_context *table, const void *pk, int pklen, const char *base_col_name) { + if (!table->block_list_stmt) return cloudsync_set_error(data, "block_materialize_column: blocks table not initialized", DBRES_MISUSE); + + // Find column index and delimiter + int col_idx = -1; + for (int i = 0; i < table->ncols; i++) { + if (strcasecmp(table->col_name[i], base_col_name) == 0) { + col_idx = i; + break; + } + } + if (col_idx < 0) return cloudsync_set_error(data, "block_materialize_column: column not found", DBRES_ERROR); + const char *delimiter = table->col_delimiter[col_idx] ? table->col_delimiter[col_idx] : BLOCK_DEFAULT_DELIMITER; + + // Build the LIKE pattern for block col_names: "base_col\x1F%" + char *like_pattern = block_build_colname(base_col_name, "%"); + if (!like_pattern) return DBRES_NOMEM; + + // Query alive blocks from blocks table joined with metadata + // block_list_stmt: SELECT b.col_value FROM blocks b JOIN meta m + // ON b.pk = m.pk AND b.col_name = m.col_name + // WHERE b.pk = ? AND b.col_name LIKE ? AND m.col_version % 2 = 1 + // ORDER BY b.col_name + dbvm_t *vm = table->block_list_stmt; + int rc = databasevm_bind_blob(vm, 1, pk, pklen); + if (rc != DBRES_OK) { cloudsync_memory_free(like_pattern); databasevm_reset(vm); return rc; } + rc = databasevm_bind_text(vm, 2, like_pattern, -1); + if (rc != DBRES_OK) { cloudsync_memory_free(like_pattern); databasevm_reset(vm); return rc; } + // Bind pk again for the join condition (parameter 3) + rc = databasevm_bind_blob(vm, 3, pk, pklen); + if (rc != DBRES_OK) { cloudsync_memory_free(like_pattern); databasevm_reset(vm); return rc; } + rc = databasevm_bind_text(vm, 4, like_pattern, -1); + if (rc != DBRES_OK) { cloudsync_memory_free(like_pattern); databasevm_reset(vm); return rc; } + + // Collect block values + const char **block_values = NULL; + int block_count = 0; + int block_cap = 0; + + while ((rc = databasevm_step(vm)) == DBRES_ROW) { + const char *value = database_column_text(vm, 0); + if (block_count >= block_cap) { + int new_cap = block_cap ? block_cap * 2 : 16; + const char **new_arr = (const char **)cloudsync_memory_realloc((void *)block_values, (uint64_t)(new_cap * sizeof(char *))); + if (!new_arr) { rc = DBRES_NOMEM; break; } + block_values = new_arr; + block_cap = new_cap; + } + block_values[block_count] = value ? cloudsync_string_dup(value) : cloudsync_string_dup(""); + block_count++; + } + databasevm_reset(vm); + cloudsync_memory_free(like_pattern); + + if (rc != DBRES_DONE && rc != DBRES_OK && rc != DBRES_ROW) { + // Free collected values + for (int i = 0; i < block_count; i++) cloudsync_memory_free((void *)block_values[i]); + if (block_values) cloudsync_memory_free((void *)block_values); + return cloudsync_set_dberror(data); + } + + // Materialize text (NULL when no alive blocks) + char *text = (block_count > 0) ? block_materialize_text(block_values, block_count, delimiter) : NULL; + for (int i = 0; i < block_count; i++) cloudsync_memory_free((void *)block_values[i]); + if (block_values) cloudsync_memory_free((void *)block_values); + if (block_count > 0 && !text) return DBRES_NOMEM; + + // Update the base table column via the col_merge_stmt (with triggers disabled) + dbvm_t *merge_vm = table->col_merge_stmt[col_idx]; + if (!merge_vm) { cloudsync_memory_free(text); return DBRES_ERROR; } + + // Bind PKs + rc = pk_decode_prikey((char *)pk, (size_t)pklen, pk_decode_bind_callback, merge_vm); + if (rc < 0) { cloudsync_memory_free(text); databasevm_reset(merge_vm); return DBRES_ERROR; } + + // Bind the text value twice (INSERT value + ON CONFLICT UPDATE value) + int npks = table->npks; + if (text) { + rc = databasevm_bind_text(merge_vm, npks + 1, text, -1); + if (rc != DBRES_OK) { cloudsync_memory_free(text); databasevm_reset(merge_vm); return rc; } + rc = databasevm_bind_text(merge_vm, npks + 2, text, -1); + if (rc != DBRES_OK) { cloudsync_memory_free(text); databasevm_reset(merge_vm); return rc; } + } else { + rc = databasevm_bind_null(merge_vm, npks + 1); + if (rc != DBRES_OK) { databasevm_reset(merge_vm); return rc; } + rc = databasevm_bind_null(merge_vm, npks + 2); + if (rc != DBRES_OK) { databasevm_reset(merge_vm); return rc; } + } + + // Execute with triggers disabled + table->enabled = 0; + SYNCBIT_SET(data); + rc = databasevm_step(merge_vm); + databasevm_reset(merge_vm); + SYNCBIT_RESET(data); + table->enabled = 1; + + cloudsync_memory_free(text); + + if (rc == DBRES_DONE) rc = DBRES_OK; + if (rc != DBRES_OK) return cloudsync_set_dberror(data); + return DBRES_OK; +} + +// Accessor for has_block_cols flag +bool table_has_block_cols (cloudsync_table_context *table) { + return table && table->has_block_cols; +} + +// Get block column algo for a given column index +col_algo_t table_col_algo (cloudsync_table_context *table, int index) { + if (!table || !table->col_algo || index < 0 || index >= table->ncols) return col_algo_normal; + return table->col_algo[index]; +} + +// Get block delimiter for a given column index +const char *table_col_delimiter (cloudsync_table_context *table, int index) { + if (!table || !table->col_delimiter || index < 0 || index >= table->ncols) return BLOCK_DEFAULT_DELIMITER; + return table->col_delimiter[index] ? table->col_delimiter[index] : BLOCK_DEFAULT_DELIMITER; +} + +// Block column struct accessors (for use outside cloudsync.c where struct is opaque) +dbvm_t *table_block_value_read_stmt (cloudsync_table_context *table) { return table ? table->block_value_read_stmt : NULL; } +dbvm_t *table_block_value_write_stmt (cloudsync_table_context *table) { return table ? table->block_value_write_stmt : NULL; } +dbvm_t *table_block_list_stmt (cloudsync_table_context *table) { return table ? table->block_list_stmt : NULL; } +const char *table_blocks_ref (cloudsync_table_context *table) { return table ? table->blocks_ref : NULL; } + +void table_set_col_delimiter (cloudsync_table_context *table, int col_idx, const char *delimiter) { + if (!table || !table->col_delimiter || col_idx < 0 || col_idx >= table->ncols) return; + if (table->col_delimiter[col_idx]) cloudsync_memory_free(table->col_delimiter[col_idx]); + table->col_delimiter[col_idx] = delimiter ? cloudsync_string_dup(delimiter) : NULL; +} + +// Find column index by name +int table_col_index (cloudsync_table_context *table, const char *col_name) { + if (!table || !col_name) return -1; + for (int i = 0; i < table->ncols; i++) { + if (strcasecmp(table->col_name[i], col_name) == 0) return i; + } + return -1; +} + int merge_insert (cloudsync_context *data, cloudsync_table_context *table, const char *insert_pk, int insert_pk_len, int64_t insert_cl, const char *insert_name, dbvalue_t *insert_value, int64_t insert_col_version, int64_t insert_db_version, const char *insert_site_id, int insert_site_id_len, int64_t insert_seq, int64_t *rowid) { // Handle DWS and AWS algorithms here // Delete-Wins Set (DWS): table_algo_crdt_dws @@ -1787,7 +2018,37 @@ int merge_insert (cloudsync_context *data, cloudsync_table_context *table, const // check if the incoming change wins and should be applied bool does_cid_win = ((needs_resurrect) || (!row_exists_locally) || (flag)); if (!does_cid_win) return DBRES_OK; - + + // Block-level LWW: if the incoming col_name is a block entry (contains \x1F), + // bypass the normal base-table write and instead store the value in the blocks table. + // The base table column will be materialized from all alive blocks. + if (block_is_block_colname(insert_name) && table->has_block_cols) { + // Store or delete block value in blocks table depending on tombstone status + if (insert_col_version % 2 == 0) { + // Tombstone: remove from blocks table + rc = block_delete_value(data, table, insert_pk, insert_pk_len, insert_name); + } else { + rc = block_store_value(data, table, insert_pk, insert_pk_len, insert_name, insert_value); + } + if (rc != DBRES_OK) return cloudsync_set_error(data, "Unable to store/delete block value", rc); + + // Set winner clock in metadata + rc = merge_set_winner_clock(data, table, insert_pk, insert_pk_len, insert_name, + insert_col_version, insert_db_version, + insert_site_id, insert_site_id_len, insert_seq, rowid); + if (rc != DBRES_OK) return cloudsync_set_error(data, "Unable to set winner clock for block", rc); + + // Materialize the full column from blocks into the base table + char *base_col = block_extract_base_colname(insert_name); + if (base_col) { + rc = block_materialize_column(data, table, insert_pk, insert_pk_len, base_col); + cloudsync_memory_free(base_col); + if (rc != DBRES_OK) return cloudsync_set_error(data, "Unable to materialize block column", rc); + } + + return DBRES_OK; + } + // perform the final column insert or update if the incoming change wins if (data->pending_batch) { // Propagate row_exists_locally to the batch on the first winning column. @@ -1806,6 +2067,88 @@ int merge_insert (cloudsync_context *data, cloudsync_table_context *table, const return rc; } +// MARK: - Block column setup - + +int cloudsync_setup_block_column (cloudsync_context *data, const char *table_name, const char *col_name, const char *delimiter) { + cloudsync_table_context *table = table_lookup(data, table_name); + if (!table) return cloudsync_set_error(data, "cloudsync_setup_block_column: table not found", DBRES_ERROR); + + // Find column index + int col_idx = table_col_index(table, col_name); + if (col_idx < 0) { + char buf[1024]; + snprintf(buf, sizeof(buf), "cloudsync_setup_block_column: column '%s' not found in table '%s'", col_name, table_name); + return cloudsync_set_error(data, buf, DBRES_ERROR); + } + + // Set column algo + table->col_algo[col_idx] = col_algo_block; + table->has_block_cols = true; + + // Set delimiter (can be NULL for default) + if (table->col_delimiter[col_idx]) { + cloudsync_memory_free(table->col_delimiter[col_idx]); + table->col_delimiter[col_idx] = NULL; + } + if (delimiter) { + table->col_delimiter[col_idx] = cloudsync_string_dup(delimiter); + } + + // Create blocks table if not already done + if (!table->blocks_ref) { + table->blocks_ref = database_build_blocks_ref(table->schema, table->name); + if (!table->blocks_ref) return DBRES_NOMEM; + + // CREATE TABLE IF NOT EXISTS + char *sql = cloudsync_memory_mprintf(SQL_BLOCKS_CREATE_TABLE, table->blocks_ref); + if (!sql) return DBRES_NOMEM; + + int rc = database_exec(data, sql); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return cloudsync_set_error(data, "Unable to create blocks table", rc); + + // Prepare block statements + // Write: upsert into blocks (pk, col_name, col_value) + sql = cloudsync_memory_mprintf(SQL_BLOCKS_UPSERT, table->blocks_ref); + if (!sql) return DBRES_NOMEM; + rc = databasevm_prepare(data, sql, (void **)&table->block_value_write_stmt, DBFLAG_PERSISTENT); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return rc; + + // Read: SELECT col_value FROM blocks WHERE pk = ? AND col_name = ? + sql = cloudsync_memory_mprintf(SQL_BLOCKS_SELECT, table->blocks_ref); + if (!sql) return DBRES_NOMEM; + rc = databasevm_prepare(data, sql, (void **)&table->block_value_read_stmt, DBFLAG_PERSISTENT); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return rc; + + // Delete: DELETE FROM blocks WHERE pk = ? AND col_name = ? + sql = cloudsync_memory_mprintf(SQL_BLOCKS_DELETE, table->blocks_ref); + if (!sql) return DBRES_NOMEM; + rc = databasevm_prepare(data, sql, (void **)&table->block_value_delete_stmt, DBFLAG_PERSISTENT); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return rc; + + // List alive blocks for materialization + sql = cloudsync_memory_mprintf(SQL_BLOCKS_LIST_ALIVE, table->blocks_ref, table->meta_ref); + if (!sql) return DBRES_NOMEM; + rc = databasevm_prepare(data, sql, (void **)&table->block_list_stmt, DBFLAG_PERSISTENT); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return rc; + } + + // Persist settings + int rc = dbutils_table_settings_set_key_value(data, table_name, col_name, "algo", "block"); + if (rc != DBRES_OK) return rc; + + if (delimiter) { + rc = dbutils_table_settings_set_key_value(data, table_name, col_name, "delimiter", delimiter); + if (rc != DBRES_OK) return rc; + } + + return DBRES_OK; +} + // MARK: - Private - bool cloudsync_config_exists (cloudsync_context *data) { @@ -2353,6 +2696,15 @@ int local_mark_insert_or_update_meta (cloudsync_table_context *table, const void return local_mark_insert_or_update_meta_impl(table, pk, pklen, col_name, 1, db_version, seq); } +int local_mark_delete_block_meta (cloudsync_table_context *table, const void *pk, size_t pklen, const char *block_colname, int64_t db_version, int seq) { + // Mark a block as deleted by setting col_version = 2 (even = deleted) + return local_mark_insert_or_update_meta_impl(table, pk, pklen, block_colname, 2, db_version, seq); +} + +int block_delete_value_external (cloudsync_context *data, cloudsync_table_context *table, const void *pk, size_t pklen, const char *block_colname) { + return block_delete_value(data, table, pk, (int)pklen, block_colname); +} + int local_mark_delete_meta (cloudsync_table_context *table, const void *pk, size_t pklen, int64_t db_version, int seq) { return local_mark_insert_or_update_meta_impl(table, pk, pklen, NULL, 2, db_version, seq); } diff --git a/src/cloudsync.h b/src/cloudsync.h index 94f9562..8673d5f 100644 --- a/src/cloudsync.h +++ b/src/cloudsync.h @@ -12,12 +12,13 @@ #include #include #include "database.h" +#include "block.h" #ifdef __cplusplus extern "C" { #endif -#define CLOUDSYNC_VERSION "0.9.118" +#define CLOUDSYNC_VERSION "0.9.200" #define CLOUDSYNC_MAX_TABLENAME_LEN 512 #define CLOUDSYNC_VALUE_NOTSET -1 @@ -103,11 +104,28 @@ const char *table_schema (cloudsync_table_context *table); int table_remove (cloudsync_context *data, cloudsync_table_context *table); void table_free (cloudsync_table_context *table); +// Block-level LWW support +bool table_has_block_cols (cloudsync_table_context *table); +col_algo_t table_col_algo (cloudsync_table_context *table, int index); +const char *table_col_delimiter (cloudsync_table_context *table, int index); +int table_col_index (cloudsync_table_context *table, const char *col_name); +int block_materialize_column (cloudsync_context *data, cloudsync_table_context *table, const void *pk, int pklen, const char *base_col_name); +int cloudsync_setup_block_column (cloudsync_context *data, const char *table_name, const char *col_name, const char *delimiter); + +// Block column accessors (avoids accessing opaque struct from outside cloudsync.c) +dbvm_t *table_block_value_read_stmt (cloudsync_table_context *table); +dbvm_t *table_block_value_write_stmt (cloudsync_table_context *table); +dbvm_t *table_block_list_stmt (cloudsync_table_context *table); +const char *table_blocks_ref (cloudsync_table_context *table); +void table_set_col_delimiter (cloudsync_table_context *table, int col_idx, const char *delimiter); + // local merge/apply int local_mark_insert_sentinel_meta (cloudsync_table_context *table, const void *pk, size_t pklen, int64_t db_version, int seq); int local_update_sentinel (cloudsync_table_context *table, const void *pk, size_t pklen, int64_t db_version, int seq); int local_mark_insert_or_update_meta (cloudsync_table_context *table, const void *pk, size_t pklen, const char *col_name, int64_t db_version, int seq); int local_mark_delete_meta (cloudsync_table_context *table, const void *pk, size_t pklen, int64_t db_version, int seq); +int local_mark_delete_block_meta (cloudsync_table_context *table, const void *pk, size_t pklen, const char *block_colname, int64_t db_version, int seq); +int block_delete_value_external (cloudsync_context *data, cloudsync_table_context *table, const void *pk, size_t pklen, const char *block_colname); int local_drop_meta (cloudsync_table_context *table, const void *pk, size_t pklen); int local_update_move_meta (cloudsync_table_context *table, const void *pk, size_t pklen, const void *pk2, size_t pklen2, int64_t db_version); diff --git a/src/database.h b/src/database.h index 5060497..56bb2d6 100644 --- a/src/database.h +++ b/src/database.h @@ -155,6 +155,7 @@ char *sql_build_insert_missing_pks_query(const char *schema, const char *table_n char *database_table_schema(const char *table_name); char *database_build_meta_ref(const char *schema, const char *table_name); char *database_build_base_ref(const char *schema, const char *table_name); +char *database_build_blocks_ref(const char *schema, const char *table_name); // OPAQUE STRUCT used by pk_context functions typedef struct cloudsync_pk_decode_bind_context cloudsync_pk_decode_bind_context; diff --git a/src/dbutils.c b/src/dbutils.c index 48fdb72..67bfeb8 100644 --- a/src/dbutils.c +++ b/src/dbutils.c @@ -357,19 +357,33 @@ int dbutils_settings_table_load_callback (void *xdata, int ncols, char **values, for (int i=0; i+3 + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef JSMN_STATIC +#define JSMN_API static +#else +#define JSMN_API extern +#endif + +/** + * JSON type identifier. Basic types are: + * o Object + * o Array + * o String + * o Other primitive: number, boolean (true/false) or null + */ +typedef enum { + JSMN_UNDEFINED = 0, + JSMN_OBJECT = 1 << 0, + JSMN_ARRAY = 1 << 1, + JSMN_STRING = 1 << 2, + JSMN_PRIMITIVE = 1 << 3 +} jsmntype_t; + +enum jsmnerr { + /* Not enough tokens were provided */ + JSMN_ERROR_NOMEM = -1, + /* Invalid character inside JSON string */ + JSMN_ERROR_INVAL = -2, + /* The string is not a full JSON packet, more bytes expected */ + JSMN_ERROR_PART = -3 +}; + +/** + * JSON token description. + * type type (object, array, string etc.) + * start start position in JSON data string + * end end position in JSON data string + */ +typedef struct jsmntok { + jsmntype_t type; + int start; + int end; + int size; +#ifdef JSMN_PARENT_LINKS + int parent; +#endif +} jsmntok_t; + +/** + * JSON parser. Contains an array of token blocks available. Also stores + * the string being parsed now and current position in that string. + */ +typedef struct jsmn_parser { + unsigned int pos; /* offset in the JSON string */ + unsigned int toknext; /* next token to allocate */ + int toksuper; /* superior token node, e.g. parent object or array */ +} jsmn_parser; + +/** + * Create JSON parser over an array of tokens + */ +JSMN_API void jsmn_init(jsmn_parser *parser); + +/** + * Run JSON parser. It parses a JSON data string into and array of tokens, each + * describing + * a single JSON object. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens); + +#ifndef JSMN_HEADER +/** + * Allocates a fresh unused token from the token pool. + */ +static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *tok; + if (parser->toknext >= num_tokens) { + return NULL; + } + tok = &tokens[parser->toknext++]; + tok->start = tok->end = -1; + tok->size = 0; +#ifdef JSMN_PARENT_LINKS + tok->parent = -1; +#endif + return tok; +} + +/** + * Fills token type and boundaries. + */ +static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type, + const int start, const int end) { + token->type = type; + token->start = start; + token->end = end; + token->size = 0; +} + +/** + * Fills next available token with JSON primitive. + */ +static int jsmn_parse_primitive(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + int start; + + start = parser->pos; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + switch (js[parser->pos]) { +#ifndef JSMN_STRICT + /* In strict mode primitive must be followed by "," or "}" or "]" */ + case ':': +#endif + case '\t': + case '\r': + case '\n': + case ' ': + case ',': + case ']': + case '}': + goto found; + default: + /* to quiet a warning from gcc*/ + break; + } + if (js[parser->pos] < 32 || js[parser->pos] >= 127) { + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } +#ifdef JSMN_STRICT + /* In strict mode primitive must be followed by a comma/object/array */ + parser->pos = start; + return JSMN_ERROR_PART; +#endif + +found: + if (tokens == NULL) { + parser->pos--; + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + parser->pos--; + return 0; +} + +/** + * Fills next token with JSON string. + */ +static int jsmn_parse_string(jsmn_parser *parser, const char *js, + const size_t len, jsmntok_t *tokens, + const size_t num_tokens) { + jsmntok_t *token; + + int start = parser->pos; + + /* Skip starting quote */ + parser->pos++; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c = js[parser->pos]; + + /* Quote: end of string */ + if (c == '\"') { + if (tokens == NULL) { + return 0; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + parser->pos = start; + return JSMN_ERROR_NOMEM; + } + jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos); +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + return 0; + } + + /* Backslash: Quoted symbol expected */ + if (c == '\\' && parser->pos + 1 < len) { + int i; + parser->pos++; + switch (js[parser->pos]) { + /* Allowed escaped symbols */ + case '\"': + case '/': + case '\\': + case 'b': + case 'f': + case 'r': + case 'n': + case 't': + break; + /* Allows escaped symbol \uXXXX */ + case 'u': + parser->pos++; + for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0'; + i++) { + /* If it isn't a hex character we have an error */ + if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */ + (js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */ + (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */ + parser->pos = start; + return JSMN_ERROR_INVAL; + } + parser->pos++; + } + parser->pos--; + break; + /* Unexpected symbol */ + default: + parser->pos = start; + return JSMN_ERROR_INVAL; + } + } + } + parser->pos = start; + return JSMN_ERROR_PART; +} + +/** + * Parse JSON string and fill tokens. + */ +JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len, + jsmntok_t *tokens, const unsigned int num_tokens) { + int r; + int i; + jsmntok_t *token; + int count = parser->toknext; + + for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) { + char c; + jsmntype_t type; + + c = js[parser->pos]; + switch (c) { + case '{': + case '[': + count++; + if (tokens == NULL) { + break; + } + token = jsmn_alloc_token(parser, tokens, num_tokens); + if (token == NULL) { + return JSMN_ERROR_NOMEM; + } + if (parser->toksuper != -1) { + jsmntok_t *t = &tokens[parser->toksuper]; +#ifdef JSMN_STRICT + /* In strict mode an object or array can't become a key */ + if (t->type == JSMN_OBJECT) { + return JSMN_ERROR_INVAL; + } +#endif + t->size++; +#ifdef JSMN_PARENT_LINKS + token->parent = parser->toksuper; +#endif + } + token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY); + token->start = parser->pos; + parser->toksuper = parser->toknext - 1; + break; + case '}': + case ']': + if (tokens == NULL) { + break; + } + type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY); +#ifdef JSMN_PARENT_LINKS + if (parser->toknext < 1) { + return JSMN_ERROR_INVAL; + } + token = &tokens[parser->toknext - 1]; + for (;;) { + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + token->end = parser->pos + 1; + parser->toksuper = token->parent; + break; + } + if (token->parent == -1) { + if (token->type != type || parser->toksuper == -1) { + return JSMN_ERROR_INVAL; + } + break; + } + token = &tokens[token->parent]; + } +#else + for (i = parser->toknext - 1; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + if (token->type != type) { + return JSMN_ERROR_INVAL; + } + parser->toksuper = -1; + token->end = parser->pos + 1; + break; + } + } + /* Error if unmatched closing bracket */ + if (i == -1) { + return JSMN_ERROR_INVAL; + } + for (; i >= 0; i--) { + token = &tokens[i]; + if (token->start != -1 && token->end == -1) { + parser->toksuper = i; + break; + } + } +#endif + break; + case '\"': + r = jsmn_parse_string(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + case '\t': + case '\r': + case '\n': + case ' ': + break; + case ':': + parser->toksuper = parser->toknext - 1; + break; + case ',': + if (tokens != NULL && parser->toksuper != -1 && + tokens[parser->toksuper].type != JSMN_ARRAY && + tokens[parser->toksuper].type != JSMN_OBJECT) { +#ifdef JSMN_PARENT_LINKS + parser->toksuper = tokens[parser->toksuper].parent; +#else + for (i = parser->toknext - 1; i >= 0; i--) { + if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) { + if (tokens[i].start != -1 && tokens[i].end == -1) { + parser->toksuper = i; + break; + } + } + } +#endif + } + break; +#ifdef JSMN_STRICT + /* In strict mode primitives are: numbers and booleans */ + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case 't': + case 'f': + case 'n': + /* And they must not be keys of the object */ + if (tokens != NULL && parser->toksuper != -1) { + const jsmntok_t *t = &tokens[parser->toksuper]; + if (t->type == JSMN_OBJECT || + (t->type == JSMN_STRING && t->size != 0)) { + return JSMN_ERROR_INVAL; + } + } +#else + /* In non-strict mode every unquoted value is a primitive */ + default: +#endif + r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens); + if (r < 0) { + return r; + } + count++; + if (parser->toksuper != -1 && tokens != NULL) { + tokens[parser->toksuper].size++; + } + break; + +#ifdef JSMN_STRICT + /* Unexpected char in strict mode */ + default: + return JSMN_ERROR_INVAL; +#endif + } + } + + if (tokens != NULL) { + for (i = parser->toknext - 1; i >= 0; i--) { + /* Unmatched opened object or array */ + if (tokens[i].start != -1 && tokens[i].end == -1) { + return JSMN_ERROR_PART; + } + } + } + + return count; +} + +/** + * Creates a new parser based over a given buffer with an array of tokens + * available. + */ +JSMN_API void jsmn_init(jsmn_parser *parser) { + parser->pos = 0; + parser->toknext = 0; + parser->toksuper = -1; +} + +#endif /* JSMN_HEADER */ + +#ifdef __cplusplus +} +#endif + +#endif /* JSMN_H */ diff --git a/src/network.c b/src/network/network.c similarity index 99% rename from src/network.c rename to src/network/network.c index 48e3257..6660005 100644 --- a/src/network.c +++ b/src/network/network.c @@ -12,9 +12,9 @@ #include #include "network.h" -#include "utils.h" -#include "dbutils.h" -#include "cloudsync.h" +#include "../utils.h" +#include "../dbutils.h" +#include "../cloudsync.h" #include "network_private.h" #define JSMN_STATIC diff --git a/src/network.h b/src/network/network.h similarity index 92% rename from src/network.h rename to src/network/network.h index 3b4db01..0c7e7de 100644 --- a/src/network.h +++ b/src/network/network.h @@ -8,7 +8,7 @@ #ifndef __CLOUDSYNC_NETWORK__ #define __CLOUDSYNC_NETWORK__ -#include "cloudsync.h" +#include "../cloudsync.h" #ifndef SQLITE_CORE #include "sqlite3ext.h" diff --git a/src/network.m b/src/network/network.m similarity index 100% rename from src/network.m rename to src/network/network.m diff --git a/src/network_private.h b/src/network/network_private.h similarity index 100% rename from src/network_private.h rename to src/network/network_private.h diff --git a/src/postgresql/cloudsync--1.0.sql b/src/postgresql/cloudsync--1.0.sql index bbd52c0..7d36f60 100644 --- a/src/postgresql/cloudsync--1.0.sql +++ b/src/postgresql/cloudsync--1.0.sql @@ -289,6 +289,16 @@ RETURNS text AS 'MODULE_PATHNAME', 'pg_cloudsync_table_schema' LANGUAGE C VOLATILE; +-- ============================================================================ +-- Block-level LWW Functions +-- ============================================================================ + +-- Materialize block-level column into base table +CREATE OR REPLACE FUNCTION cloudsync_text_materialize(table_name text, col_name text, VARIADIC pk_values "any") +RETURNS boolean +AS 'MODULE_PATHNAME', 'cloudsync_text_materialize' +LANGUAGE C VOLATILE; + -- ============================================================================ -- Type Casts -- ============================================================================ diff --git a/src/postgresql/cloudsync_postgresql.c b/src/postgresql/cloudsync_postgresql.c index aaa8557..9e6cd85 100644 --- a/src/postgresql/cloudsync_postgresql.c +++ b/src/postgresql/cloudsync_postgresql.c @@ -32,6 +32,7 @@ // CloudSync headers (after PostgreSQL headers) #include "../cloudsync.h" +#include "../block.h" #include "../database.h" #include "../dbutils.h" #include "../pk.h" @@ -129,6 +130,9 @@ void _PG_init (void) { // Initialize memory debugger (NOOP in production) cloudsync_memory_init(1); + + // Set fractional-indexing allocator to use cloudsync memory + block_init_allocator(); } void _PG_fini (void) { @@ -597,7 +601,25 @@ Datum cloudsync_set_column (PG_FUNCTION_ARGS) { PG_TRY(); { - dbutils_table_settings_set_key_value(data, tbl, col, key, value); + // Handle block column setup: cloudsync_set_column('tbl', 'col', 'algo', 'block') + if (key && value && strcmp(key, "algo") == 0 && strcmp(value, "block") == 0) { + int rc = cloudsync_setup_block_column(data, tbl, col, NULL); + if (rc != DBRES_OK) { + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("%s", cloudsync_errmsg(data)))); + } + } else { + // Handle delimiter setting: cloudsync_set_column('tbl', 'col', 'delimiter', '\n\n') + if (key && strcmp(key, "delimiter") == 0) { + cloudsync_table_context *table = table_lookup(data, tbl); + if (table) { + int col_idx = table_col_index(table, col); + if (col_idx >= 0 && table_col_algo(table, col_idx) == col_algo_block) { + table_set_col_delimiter(table, col_idx, value); + } + } + } + dbutils_table_settings_set_key_value(data, tbl, col, key, value); + } } PG_CATCH(); { @@ -1120,6 +1142,10 @@ Datum cloudsync_pk_encode (PG_FUNCTION_ARGS) { errmsg("cloudsync_pk_encode requires at least one primary key value"))); } + // Normalize all values to text for consistent PK encoding + // (PG triggers cast PK values to ::text; SQL callers must match) + pgvalues_normalize_to_text(argv, argc); + size_t pklen = 0; char *encoded = pk_encode_prikey((dbvalue_t **)argv, argc, NULL, &pklen); if (!encoded || encoded == PRIKEY_NULL_CONSTRAINT_ERROR) { @@ -1258,6 +1284,9 @@ Datum cloudsync_insert (PG_FUNCTION_ARGS) { // Extract PK values from VARIADIC "any" (args starting from index 1) cleanup.argv = pgvalues_from_args(fcinfo, 1, &cleanup.argc); + // Normalize PK values to text for consistent encoding + pgvalues_normalize_to_text(cleanup.argv, cleanup.argc); + // Verify we have the correct number of PK columns int expected_pks = table_count_pks(table); if (cleanup.argc != expected_pks) { @@ -1295,8 +1324,56 @@ Datum cloudsync_insert (PG_FUNCTION_ARGS) { if (rc == DBRES_OK) { // Process each non-primary key column for insert or update for (int i = 0; i < table_count_cols(table); i++) { - rc = local_mark_insert_or_update_meta(table, cleanup.pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); - if (rc != DBRES_OK) break; + if (table_col_algo(table, i) == col_algo_block) { + // Block column: read value from base table, split into blocks, store each block + dbvm_t *val_vm = table_column_lookup(table, table_colname(table, i), false, NULL); + if (!val_vm) { rc = DBRES_ERROR; break; } + + int bind_rc = pk_decode_prikey(cleanup.pk, pklen, pk_decode_bind_callback, (void *)val_vm); + if (bind_rc < 0) { databasevm_reset(val_vm); rc = DBRES_ERROR; break; } + + int step_rc = databasevm_step(val_vm); + if (step_rc == DBRES_ROW) { + const char *text = database_column_text(val_vm, 0); + const char *delim = table_col_delimiter(table, i); + const char *col = table_colname(table, i); + + block_list_t *blocks = block_split(text ? text : "", delim); + if (blocks) { + char **positions = block_initial_positions(blocks->count); + if (positions) { + for (int b = 0; b < blocks->count; b++) { + char *block_cn = block_build_colname(col, positions[b]); + if (block_cn) { + rc = local_mark_insert_or_update_meta(table, cleanup.pk, pklen, block_cn, db_version, cloudsync_bumpseq(data)); + + // Store block value in blocks table + dbvm_t *wvm = table_block_value_write_stmt(table); + if (wvm && rc == DBRES_OK) { + databasevm_bind_blob(wvm, 1, cleanup.pk, (int)pklen); + databasevm_bind_text(wvm, 2, block_cn, -1); + databasevm_bind_text(wvm, 3, blocks->entries[b].content, -1); + databasevm_step(wvm); + databasevm_reset(wvm); + } + + cloudsync_memory_free(block_cn); + } + cloudsync_memory_free(positions[b]); + if (rc != DBRES_OK) break; + } + cloudsync_memory_free(positions); + } + block_list_free(blocks); + } + } + databasevm_reset(val_vm); + if (step_rc == DBRES_ROW || step_rc == DBRES_DONE) { if (rc == DBRES_OK) continue; } + if (rc != DBRES_OK) break; + } else { + rc = local_mark_insert_or_update_meta(table, cleanup.pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); + if (rc != DBRES_OK) break; + } } } @@ -1353,6 +1430,9 @@ Datum cloudsync_delete (PG_FUNCTION_ARGS) { // Extract PK values from VARIADIC "any" (args starting from index 1) cleanup.argv = pgvalues_from_args(fcinfo, 1, &cleanup.argc); + // Normalize PK values to text for consistent encoding + pgvalues_normalize_to_text(cleanup.argv, cleanup.argc); + int expected_pks = table_count_pks(table); if (cleanup.argc != expected_pks) { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("Expected %d primary key values, got %d", expected_pks, cleanup.argc))); @@ -1595,8 +1675,99 @@ Datum cloudsync_update_finalfn (PG_FUNCTION_ARGS) { if (col_index >= payload->count) break; if (dbutils_value_compare((dbvalue_t *)payload->old_values[col_index], (dbvalue_t *)payload->new_values[col_index]) != 0) { - rc = local_mark_insert_or_update_meta(table, pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); - if (rc != DBRES_OK) goto cleanup; + if (table_col_algo(table, i) == col_algo_block) { + // Block column: diff old and new text, emit per-block metadata changes + const char *new_text = (const char *)database_value_text(payload->new_values[col_index]); + const char *delim = table_col_delimiter(table, i); + const char *col = table_colname(table, i); + + // Read existing blocks from blocks table + block_list_t *old_blocks = block_list_create_empty(); + char *like_pattern = block_build_colname(col, "%"); + if (like_pattern && old_blocks) { + char *list_sql = cloudsync_memory_mprintf( + "SELECT col_name, col_value FROM %s WHERE pk = $1 AND col_name LIKE $2 ORDER BY col_name COLLATE \"C\"", + table_blocks_ref(table)); + if (list_sql) { + dbvm_t *list_vm = NULL; + if (databasevm_prepare(data, list_sql, &list_vm, 0) == DBRES_OK) { + databasevm_bind_blob(list_vm, 1, pk, (int)pklen); + databasevm_bind_text(list_vm, 2, like_pattern, -1); + while (databasevm_step(list_vm) == DBRES_ROW) { + const char *bcn = database_column_text(list_vm, 0); + const char *bval = database_column_text(list_vm, 1); + const char *pos = block_extract_position_id(bcn); + if (pos && old_blocks) { + block_list_add(old_blocks, bval ? bval : "", pos); + } + } + databasevm_finalize(list_vm); + } + cloudsync_memory_free(list_sql); + } + } + + // Split new text into parts (NULL text = all blocks removed) + block_list_t *new_blocks = new_text ? block_split(new_text, delim) : block_list_create_empty(); + if (new_blocks && old_blocks) { + // Build array of new content strings (NULL when count is 0) + const char **new_parts = NULL; + if (new_blocks->count > 0) { + new_parts = (const char **)cloudsync_memory_alloc( + (uint64_t)(new_blocks->count * sizeof(char *))); + if (new_parts) { + for (int b = 0; b < new_blocks->count; b++) { + new_parts[b] = new_blocks->entries[b].content; + } + } + } + + if (new_parts || new_blocks->count == 0) { + block_diff_t *diff = block_diff(old_blocks->entries, old_blocks->count, + new_parts, new_blocks->count); + if (diff) { + for (int d = 0; d < diff->count; d++) { + block_diff_entry_t *de = &diff->entries[d]; + char *block_cn = block_build_colname(col, de->position_id); + if (!block_cn) continue; + + if (de->type == BLOCK_DIFF_ADDED || de->type == BLOCK_DIFF_MODIFIED) { + rc = local_mark_insert_or_update_meta(table, pk, pklen, block_cn, + db_version, cloudsync_bumpseq(data)); + // Store block value + if (rc == DBRES_OK && table_block_value_write_stmt(table)) { + dbvm_t *wvm = table_block_value_write_stmt(table); + databasevm_bind_blob(wvm, 1, pk, (int)pklen); + databasevm_bind_text(wvm, 2, block_cn, -1); + databasevm_bind_text(wvm, 3, de->content, -1); + databasevm_step(wvm); + databasevm_reset(wvm); + } + } else if (de->type == BLOCK_DIFF_REMOVED) { + // Mark block as deleted in metadata (even col_version) + rc = local_mark_delete_block_meta(table, pk, pklen, block_cn, + db_version, cloudsync_bumpseq(data)); + // Remove from blocks table + if (rc == DBRES_OK) { + block_delete_value_external(data, table, pk, pklen, block_cn); + } + } + cloudsync_memory_free(block_cn); + if (rc != DBRES_OK) break; + } + block_diff_free(diff); + } + if (new_parts) cloudsync_memory_free((void *)new_parts); + } + } + if (new_blocks) block_list_free(new_blocks); + if (old_blocks) block_list_free(old_blocks); + if (like_pattern) cloudsync_memory_free(like_pattern); + if (rc != DBRES_OK) goto cleanup; + } else { + rc = local_mark_insert_or_update_meta(table, pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); + if (rc != DBRES_OK) goto cleanup; + } } } @@ -1957,7 +2128,42 @@ Datum cloudsync_col_value(PG_FUNCTION_ARGS) { if (!table) { ereport(ERROR, (errmsg("Unable to retrieve table name %s in clousdsync_col_value.", table_name))); } - + + // Block column: if col_name contains \x1F, read from blocks table + if (block_is_block_colname(col_name) && table_has_block_cols(table)) { + dbvm_t *bvm = table_block_value_read_stmt(table); + if (!bvm) { + bytea *null_encoded = cloudsync_encode_null_value(); + PG_RETURN_BYTEA_P(null_encoded); + } + + bytea *encoded_pk_b = PG_GETARG_BYTEA_P(2); + size_t b_pk_len = (size_t)VARSIZE_ANY_EXHDR(encoded_pk_b); + int brc = databasevm_bind_blob(bvm, 1, VARDATA_ANY(encoded_pk_b), (uint64_t)b_pk_len); + if (brc != DBRES_OK) { databasevm_reset(bvm); ereport(ERROR, (errmsg("cloudsync_col_value block bind error"))); } + brc = databasevm_bind_text(bvm, 2, col_name, -1); + if (brc != DBRES_OK) { databasevm_reset(bvm); ereport(ERROR, (errmsg("cloudsync_col_value block bind error"))); } + + brc = databasevm_step(bvm); + if (brc == DBRES_ROW) { + size_t blob_len = 0; + const void *blob = database_column_blob(bvm, 0, &blob_len); + bytea *result = NULL; + if (blob && blob_len > 0) { + result = (bytea *)palloc(VARHDRSZ + blob_len); + SET_VARSIZE(result, VARHDRSZ + blob_len); + memcpy(VARDATA(result), blob, blob_len); + } + databasevm_reset(bvm); + if (result) PG_RETURN_BYTEA_P(result); + PG_RETURN_NULL(); + } else { + databasevm_reset(bvm); + bytea *null_encoded = cloudsync_encode_null_value(); + PG_RETURN_BYTEA_P(null_encoded); + } + } + // extract the right col_value vm associated to the column name dbvm_t *vm = table_column_lookup(table, col_name, false, NULL); if (!vm) { @@ -2002,6 +2208,73 @@ Datum cloudsync_col_value(PG_FUNCTION_ARGS) { PG_RETURN_NULL(); // unreachable, silences compiler } +// MARK: - Block-level LWW - + +PG_FUNCTION_INFO_V1(cloudsync_text_materialize); +Datum cloudsync_text_materialize (PG_FUNCTION_ARGS) { + if (PG_ARGISNULL(0) || PG_ARGISNULL(1)) { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cloudsync_text_materialize: table_name and col_name cannot be NULL"))); + } + + const char *table_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + const char *col_name = text_to_cstring(PG_GETARG_TEXT_PP(1)); + + cloudsync_context *data = get_cloudsync_context(); + cloudsync_pg_cleanup_state cleanup = {0}; + + int spi_rc = SPI_connect(); + if (spi_rc != SPI_OK_CONNECT) { + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("SPI_connect failed: %d", spi_rc))); + } + cleanup.spi_connected = true; + + PG_ENSURE_ERROR_CLEANUP(cloudsync_pg_cleanup, PointerGetDatum(&cleanup)); + { + cloudsync_table_context *table = table_lookup(data, table_name); + if (!table) { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("Unable to retrieve table name %s in cloudsync_text_materialize", table_name))); + } + + int col_idx = table_col_index(table, col_name); + if (col_idx < 0 || table_col_algo(table, col_idx) != col_algo_block) { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("Column %s in table %s is not configured as block-level", col_name, table_name))); + } + + // Extract PK values from VARIADIC "any" (args starting from index 2) + cleanup.argv = pgvalues_from_args(fcinfo, 2, &cleanup.argc); + + // Normalize PK values to text for consistent encoding + pgvalues_normalize_to_text(cleanup.argv, cleanup.argc); + + int expected_pks = table_count_pks(table); + if (cleanup.argc != expected_pks) { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("Expected %d primary key values, got %d", expected_pks, cleanup.argc))); + } + + size_t pklen = sizeof(cleanup.pk_buffer); + cleanup.pk = pk_encode_prikey((dbvalue_t **)cleanup.argv, cleanup.argc, cleanup.pk_buffer, &pklen); + if (!cleanup.pk || cleanup.pk == PRIKEY_NULL_CONSTRAINT_ERROR) { + if (cleanup.pk == PRIKEY_NULL_CONSTRAINT_ERROR) cleanup.pk = NULL; + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("Failed to encode primary key(s)"))); + } + + int rc = block_materialize_column(data, table, cleanup.pk, (int)pklen, col_name); + if (rc != DBRES_OK) { + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("%s", cloudsync_errmsg(data)))); + } + } + PG_END_ENSURE_ERROR_CLEANUP(cloudsync_pg_cleanup, PointerGetDatum(&cleanup)); + + cloudsync_pg_cleanup(0, PointerGetDatum(&cleanup)); + PG_RETURN_BOOL(true); +} + // Track SRF execution state across calls typedef struct { Portal portal; @@ -2149,6 +2422,20 @@ static char * build_union_sql (void) { } SPI_freetuptable(SPI_tuptable); + // Check if blocks table exists for this table + char blocks_tbl_name[1024]; + snprintf(blocks_tbl_name, sizeof(blocks_tbl_name), "%s_cloudsync_blocks", base); + StringInfoData btq; + initStringInfo(&btq); + appendStringInfo(&btq, + "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relname = %s AND n.nspname = %s AND c.relkind = 'r'", + quote_literal_cstr(blocks_tbl_name), nsp_lit); + int btrc = SPI_execute(btq.data, true, 1); + bool has_blocks_table = (btrc == SPI_OK_SELECT && SPI_processed > 0); + if (SPI_tuptable) { SPI_freetuptable(SPI_tuptable); SPI_tuptable = NULL; } + pfree(btq.data); + /* Collect all base-table columns to build CASE over t1.col_name */ StringInfoData colq; initStringInfo(&colq); @@ -2169,13 +2456,22 @@ static char * build_union_sql (void) { ereport(ERROR, (errmsg("cloudsync: unable to resolve columns for %s.%s", nsp, base))); } uint64 ncols = SPI_processed; - + StringInfoData caseexpr; initStringInfo(&caseexpr); appendStringInfoString(&caseexpr, "CASE " "WHEN t1.col_name = '" CLOUDSYNC_TOMBSTONE_VALUE "' THEN " CLOUDSYNC_NULL_VALUE_BYTEA " " "WHEN b.ctid IS NULL THEN " CLOUDSYNC_RLS_RESTRICTED_VALUE_BYTEA " " + ); + if (has_blocks_table) { + appendStringInfo(&caseexpr, + "WHEN t1.col_name LIKE '%%' || chr(31) || '%%' THEN " + "(SELECT cloudsync_encode_value(blk.col_value) FROM %s.\"%s_cloudsync_blocks\" blk " + "WHERE blk.pk = t1.pk AND blk.col_name = t1.col_name) ", + quote_identifier(nsp), base); + } + appendStringInfoString(&caseexpr, "ELSE CASE t1.col_name " ); diff --git a/src/postgresql/database_postgresql.c b/src/postgresql/database_postgresql.c index 58a6a2a..3fc6310 100644 --- a/src/postgresql/database_postgresql.c +++ b/src/postgresql/database_postgresql.c @@ -68,6 +68,8 @@ typedef struct { // Params int nparams; Oid types[MAX_PARAMS]; + Oid prepared_types[MAX_PARAMS]; // types used when plan was SPI_prepare'd + int prepared_nparams; // nparams at prepare time Datum values[MAX_PARAMS]; char nulls[MAX_PARAMS]; bool executed_nonselect; // non-select executed already @@ -433,6 +435,17 @@ char *database_build_base_ref (const char *schema, const char *table_name) { return cloudsync_memory_mprintf("\"%s\"", escaped_table); } +char *database_build_blocks_ref (const char *schema, const char *table_name) { + char escaped_table[512]; + sql_escape_identifier(table_name, escaped_table, sizeof(escaped_table)); + if (schema) { + char escaped_schema[512]; + sql_escape_identifier(schema, escaped_schema, sizeof(escaped_schema)); + return cloudsync_memory_mprintf("\"%s\".\"%s_cloudsync_blocks\"", escaped_schema, escaped_table); + } + return cloudsync_memory_mprintf("\"%s_cloudsync_blocks\"", escaped_table); +} + // Schema-aware SQL builder for PostgreSQL: deletes columns not in schema or pkcol. // Schema parameter: pass empty string to fall back to current_schema() via SQL. char *sql_build_delete_cols_not_in_schema_query (const char *schema, const char *table_name, const char *meta_ref, const char *pkcol) { @@ -1314,7 +1327,7 @@ static int database_create_insert_trigger_internal (cloudsync_context *data, con char sql[2048]; snprintf(sql, sizeof(sql), - "SELECT string_agg('NEW.' || quote_ident(kcu.column_name), ',' ORDER BY kcu.ordinal_position) " + "SELECT string_agg('NEW.' || quote_ident(kcu.column_name) || '::text', ',' ORDER BY kcu.ordinal_position) " "FROM information_schema.table_constraints tc " "JOIN information_schema.key_column_usage kcu " " ON tc.constraint_name = kcu.constraint_name " @@ -1582,7 +1595,7 @@ static int database_create_delete_trigger_internal (cloudsync_context *data, con char sql[2048]; snprintf(sql, sizeof(sql), - "SELECT string_agg('OLD.' || quote_ident(kcu.column_name), ',' ORDER BY kcu.ordinal_position) " + "SELECT string_agg('OLD.' || quote_ident(kcu.column_name) || '::text', ',' ORDER BY kcu.ordinal_position) " "FROM information_schema.table_constraints tc " "JOIN information_schema.key_column_usage kcu " " ON tc.constraint_name = kcu.constraint_name " @@ -2047,9 +2060,13 @@ int databasevm_step0 (pg_stmt_t *stmt) { ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("Unable to prepare SQL statement"))); } - + SPI_keepplan(stmt->plan); stmt->plan_is_prepared = true; + + // Save the types used for this plan so we can detect type changes + memcpy(stmt->prepared_types, stmt->types, sizeof(Oid) * stmt->nparams); + stmt->prepared_nparams = stmt->nparams; } PG_CATCH(); { @@ -2086,6 +2103,26 @@ int databasevm_step (dbvm_t *vm) { cloudsync_context *data = stmt->data; cloudsync_reset_error(data); + // If plan is prepared but parameter types have changed since preparation, + // free the old plan and re-prepare with new types. This happens when the same + // prepared statement is reused with different PK encodings (e.g., integer vs text). + if (stmt->plan_is_prepared && stmt->plan) { + bool types_changed = (stmt->nparams != stmt->prepared_nparams); + if (!types_changed) { + for (int i = 0; i < stmt->nparams; i++) { + if (stmt->types[i] != stmt->prepared_types[i]) { + types_changed = true; + break; + } + } + } + if (types_changed) { + SPI_freeplan(stmt->plan); + stmt->plan = NULL; + stmt->plan_is_prepared = false; + } + } + if (!stmt->plan_is_prepared) { int rc = databasevm_step0(stmt); if (rc != DBRES_OK) return rc; diff --git a/src/postgresql/pgvalue.c b/src/postgresql/pgvalue.c index 01d9cf6..69fd626 100644 --- a/src/postgresql/pgvalue.c +++ b/src/postgresql/pgvalue.c @@ -169,3 +169,30 @@ pgvalue_t **pgvalues_from_args(FunctionCallInfo fcinfo, int start_arg, int *out_ if (out_count) *out_count = count; return values; } + +void pgvalues_normalize_to_text(pgvalue_t **values, int count) { + // Convert all non-text pgvalues to text representation. + // This ensures PK encoding is consistent regardless of whether the caller + // passes native types (e.g., integer 1) or text representations (e.g., '1'). + // The UPDATE trigger casts all values to ::text, so INSERT trigger and + // SQL functions must do the same for PK encoding consistency. + if (!values) return; + + for (int i = 0; i < count; i++) { + pgvalue_t *v = values[i]; + if (!v || v->isnull) continue; + if (pgvalue_is_text_type(v->typeid)) continue; + + // Convert to text using the type's output function + const char *cstr = database_value_text((dbvalue_t *)v); + if (!cstr) continue; + + // Create a new text datum + text *t = cstring_to_text(cstr); + pgvalue_t *new_v = pgvalue_create(PointerGetDatum(t), TEXTOID, -1, v->collation, false); + if (new_v) { + pgvalue_free(v); + values[i] = new_v; + } + } +} diff --git a/src/postgresql/pgvalue.h b/src/postgresql/pgvalue.h index 51d4c0f..3fbd28b 100644 --- a/src/postgresql/pgvalue.h +++ b/src/postgresql/pgvalue.h @@ -39,5 +39,6 @@ bool pgvalue_is_text_type(Oid typeid); int pgvalue_dbtype(pgvalue_t *v); pgvalue_t **pgvalues_from_array(ArrayType *array, int *out_count); pgvalue_t **pgvalues_from_args(FunctionCallInfo fcinfo, int start_arg, int *out_count); +void pgvalues_normalize_to_text(pgvalue_t **values, int count); #endif // CLOUDSYNC_PGVALUE_H diff --git a/src/postgresql/sql_postgresql.c b/src/postgresql/sql_postgresql.c index 3af2c8c..db9c2de 100644 --- a/src/postgresql/sql_postgresql.c +++ b/src/postgresql/sql_postgresql.c @@ -28,7 +28,7 @@ const char * const SQL_TABLE_SETTINGS_DELETE_ALL_FOR_TABLE = const char * const SQL_TABLE_SETTINGS_REPLACE = "INSERT INTO cloudsync_table_settings (tbl_name, col_name, key, value) VALUES ($1, $2, $3, $4) " - "ON CONFLICT (tbl_name, key) DO UPDATE SET col_name = EXCLUDED.col_name, value = EXCLUDED.value;"; + "ON CONFLICT (tbl_name, col_name, key) DO UPDATE SET value = EXCLUDED.value;"; const char * const SQL_TABLE_SETTINGS_DELETE_ONE = "DELETE FROM cloudsync_table_settings WHERE (tbl_name=$1 AND col_name=$2 AND key=$3);"; @@ -40,7 +40,7 @@ const char * const SQL_SETTINGS_LOAD_GLOBAL = "SELECT key, value FROM cloudsync_settings;"; const char * const SQL_SETTINGS_LOAD_TABLE = - "SELECT lower(tbl_name), lower(col_name), key, value FROM cloudsync_table_settings ORDER BY tbl_name;"; + "SELECT lower(tbl_name), lower(col_name), key, value FROM cloudsync_table_settings ORDER BY tbl_name, col_name;"; const char * const SQL_CREATE_SETTINGS_TABLE = "CREATE TABLE IF NOT EXISTS cloudsync_settings (key TEXT PRIMARY KEY NOT NULL, value TEXT);" @@ -75,7 +75,7 @@ const char * const SQL_INSERT_SITE_ID_ROWID = "INSERT INTO cloudsync_site_id (id, site_id) VALUES ($1, $2);"; const char * const SQL_CREATE_TABLE_SETTINGS_TABLE = - "CREATE TABLE IF NOT EXISTS cloudsync_table_settings (tbl_name TEXT NOT NULL, col_name TEXT NOT NULL, key TEXT, value TEXT, PRIMARY KEY(tbl_name,key));"; + "CREATE TABLE IF NOT EXISTS cloudsync_table_settings (tbl_name TEXT NOT NULL, col_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT, PRIMARY KEY(tbl_name,col_name,key));"; const char * const SQL_CREATE_SCHEMA_VERSIONS_TABLE = "CREATE TABLE IF NOT EXISTS cloudsync_schema_versions (hash BIGINT PRIMARY KEY, seq INTEGER NOT NULL)"; @@ -408,3 +408,29 @@ const char * const SQL_CLOUDSYNC_SELECT_PKS_NOT_IN_SYNC_FOR_COL_FILTERED = "SELECT 1 FROM %s _cstemp2 " "WHERE _cstemp2.pk = _cstemp1.pk AND _cstemp2.col_name = $1" ");"; + +// MARK: Blocks (block-level LWW) + +const char * const SQL_BLOCKS_CREATE_TABLE = + "CREATE TABLE IF NOT EXISTS %s (" + "pk BYTEA NOT NULL, " + "col_name TEXT COLLATE \"C\" NOT NULL, " + "col_value TEXT, " + "PRIMARY KEY (pk, col_name))"; + +const char * const SQL_BLOCKS_UPSERT = + "INSERT INTO %s (pk, col_name, col_value) VALUES ($1, $2, $3) " + "ON CONFLICT (pk, col_name) DO UPDATE SET col_value = EXCLUDED.col_value"; + +const char * const SQL_BLOCKS_SELECT = + "SELECT col_value FROM %s WHERE pk = $1 AND col_name = $2"; + +const char * const SQL_BLOCKS_DELETE = + "DELETE FROM %s WHERE pk = $1 AND col_name = $2"; + +const char * const SQL_BLOCKS_LIST_ALIVE = + "SELECT b.col_value FROM %s b " + "JOIN %s m ON b.pk = m.pk AND b.col_name = m.col_name " + "WHERE b.pk = $1 AND b.col_name LIKE $2 " + "AND m.pk = $3 AND m.col_name LIKE $4 AND m.col_version %% 2 = 1 " + "ORDER BY b.col_name COLLATE \"C\""; diff --git a/src/sql.h b/src/sql.h index 7c14988..dfa394e 100644 --- a/src/sql.h +++ b/src/sql.h @@ -67,4 +67,11 @@ extern const char * const SQL_CLOUDSYNC_SELECT_PKS_NOT_IN_SYNC_FOR_COL; extern const char * const SQL_CLOUDSYNC_SELECT_PKS_NOT_IN_SYNC_FOR_COL_FILTERED; extern const char * const SQL_CHANGES_INSERT_ROW; +// BLOCKS (block-level LWW) +extern const char * const SQL_BLOCKS_CREATE_TABLE; +extern const char * const SQL_BLOCKS_UPSERT; +extern const char * const SQL_BLOCKS_SELECT; +extern const char * const SQL_BLOCKS_DELETE; +extern const char * const SQL_BLOCKS_LIST_ALIVE; + #endif diff --git a/src/sqlite/cloudsync_sqlite.c b/src/sqlite/cloudsync_sqlite.c index 8333111..ebdd1cc 100644 --- a/src/sqlite/cloudsync_sqlite.c +++ b/src/sqlite/cloudsync_sqlite.c @@ -9,11 +9,12 @@ #include "cloudsync_changes_sqlite.h" #include "../pk.h" #include "../cloudsync.h" +#include "../block.h" #include "../database.h" #include "../dbutils.h" #ifndef CLOUDSYNC_OMIT_NETWORK -#include "../network.h" +#include "../network/network.h" #endif #ifndef SQLITE_CORE @@ -139,13 +140,34 @@ void dbsync_set (sqlite3_context *context, int argc, sqlite3_value **argv) { void dbsync_set_column (sqlite3_context *context, int argc, sqlite3_value **argv) { DEBUG_FUNCTION("cloudsync_set_column"); - + const char *tbl = (const char *)database_value_text(argv[0]); const char *col = (const char *)database_value_text(argv[1]); const char *key = (const char *)database_value_text(argv[2]); const char *value = (const char *)database_value_text(argv[3]); - + cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); + + // Handle block column setup: cloudsync_set_column('tbl', 'col', 'algo', 'block') + if (key && value && strcmp(key, "algo") == 0 && strcmp(value, "block") == 0) { + int rc = cloudsync_setup_block_column(data, tbl, col, NULL); + if (rc != DBRES_OK) { + sqlite3_result_error(context, cloudsync_errmsg(data), -1); + } + return; + } + + // Handle delimiter setting: cloudsync_set_column('tbl', 'col', 'delimiter', '\n\n') + if (key && strcmp(key, "delimiter") == 0) { + cloudsync_table_context *table = table_lookup(data, tbl); + if (table) { + int col_idx = table_col_index(table, col); + if (col_idx >= 0 && table_col_algo(table, col_idx) == col_algo_block) { + table_set_col_delimiter(table, col_idx, value); + } + } + } + dbutils_table_settings_set_key_value(data, tbl, col, key, value); } @@ -218,7 +240,7 @@ void dbsync_col_value (sqlite3_context *context, int argc, sqlite3_value **argv) sqlite3_result_null(context); return; } - + // lookup table const char *table_name = (const char *)database_value_text(argv[0]); cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); @@ -227,18 +249,42 @@ void dbsync_col_value (sqlite3_context *context, int argc, sqlite3_value **argv) dbsync_set_error(context, "Unable to retrieve table name %s in clousdsync_colvalue.", table_name); return; } - + + // Block column: if col_name contains \x1F, read from blocks table + if (block_is_block_colname(col_name) && table_has_block_cols(table)) { + dbvm_t *bvm = table_block_value_read_stmt(table); + if (!bvm) { + sqlite3_result_null(context); + return; + } + int rc = databasevm_bind_blob(bvm, 1, database_value_blob(argv[2]), database_value_bytes(argv[2])); + if (rc != DBRES_OK) { databasevm_reset(bvm); sqlite3_result_error(context, database_errmsg(data), -1); return; } + rc = databasevm_bind_text(bvm, 2, col_name, -1); + if (rc != DBRES_OK) { databasevm_reset(bvm); sqlite3_result_error(context, database_errmsg(data), -1); return; } + + rc = databasevm_step(bvm); + if (rc == SQLITE_ROW) { + sqlite3_result_value(context, database_column_value(bvm, 0)); + } else if (rc == SQLITE_DONE) { + sqlite3_result_null(context); + } else { + sqlite3_result_error(context, database_errmsg(data), -1); + } + databasevm_reset(bvm); + return; + } + // extract the right col_value vm associated to the column name sqlite3_stmt *vm = table_column_lookup(table, col_name, false, NULL); if (!vm) { sqlite3_result_error(context, "Unable to retrieve column value precompiled statement in clousdsync_colvalue.", -1); return; } - + // bind primary key values int rc = pk_decode_prikey((char *)database_value_blob(argv[2]), (size_t)database_value_bytes(argv[2]), pk_decode_bind_callback, (void *)vm); if (rc < 0) goto cleanup; - + // execute vm rc = databasevm_step(vm); if (rc == SQLITE_DONE) { @@ -249,7 +295,7 @@ void dbsync_col_value (sqlite3_context *context, int argc, sqlite3_value **argv) rc = SQLITE_OK; sqlite3_result_value(context, database_column_value(vm, 0)); } - + cleanup: if (rc != SQLITE_OK) { sqlite3_result_error(context, database_errmsg(data), -1); @@ -372,11 +418,59 @@ void dbsync_insert (sqlite3_context *context, int argc, sqlite3_value **argv) { // process each non-primary key column for insert or update for (int i=0; icount); + if (positions) { + for (int b = 0; b < blocks->count; b++) { + char *block_cn = block_build_colname(col, positions[b]); + if (block_cn) { + rc = local_mark_insert_or_update_meta(table, pk, pklen, block_cn, db_version, cloudsync_bumpseq(data)); + + // Store block value in blocks table + dbvm_t *wvm = table_block_value_write_stmt(table); + if (wvm && rc == SQLITE_OK) { + databasevm_bind_blob(wvm, 1, pk, (int)pklen); + databasevm_bind_text(wvm, 2, block_cn, -1); + databasevm_bind_text(wvm, 3, blocks->entries[b].content, -1); + databasevm_step(wvm); + databasevm_reset(wvm); + } + + cloudsync_memory_free(block_cn); + } + cloudsync_memory_free(positions[b]); + if (rc != SQLITE_OK) break; + } + cloudsync_memory_free(positions); + } + block_list_free(blocks); + } + } + databasevm_reset((dbvm_t *)val_vm); + if (rc == DBRES_ROW || rc == DBRES_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto cleanup; + } else { + // Regular column: mark as inserted or updated in the metadata + rc = local_mark_insert_or_update_meta(table, pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); + if (rc != SQLITE_OK) goto cleanup; + } } - + cleanup: if (rc != SQLITE_OK) sqlite3_result_error(context, database_errmsg(data), -1); // free memory if the primary key was dynamically allocated @@ -596,10 +690,103 @@ void dbsync_update_final (sqlite3_context *context) { int col_index = table_count_pks(table) + i; // Regular columns start after primary keys if (dbutils_value_compare(payload->old_values[col_index], payload->new_values[col_index]) != 0) { - // if a column value has changed, mark it as updated in the metadata - // columns are in cid order - rc = local_mark_insert_or_update_meta(table, pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); - if (rc != SQLITE_OK) goto cleanup; + if (table_col_algo(table, i) == col_algo_block) { + // Block column: diff old and new text, emit per-block metadata changes + const char *new_text = (const char *)database_value_text(payload->new_values[col_index]); + const char *delim = table_col_delimiter(table, i); + const char *col = table_colname(table, i); + + // Read existing blocks from blocks table + block_list_t *old_blocks = block_list_create_empty(); + if (table_block_list_stmt(table)) { + char *like_pattern = block_build_colname(col, "%"); + if (like_pattern) { + // Query blocks table directly for existing block names and values + char *list_sql = cloudsync_memory_mprintf( + "SELECT col_name, col_value FROM %s WHERE pk = ?1 AND col_name LIKE ?2 ORDER BY col_name", + table_blocks_ref(table)); + if (list_sql) { + dbvm_t *list_vm = NULL; + if (databasevm_prepare(data, list_sql, &list_vm, 0) == DBRES_OK) { + databasevm_bind_blob(list_vm, 1, pk, (int)pklen); + databasevm_bind_text(list_vm, 2, like_pattern, -1); + while (databasevm_step(list_vm) == DBRES_ROW) { + const char *bcn = database_column_text(list_vm, 0); + const char *bval = database_column_text(list_vm, 1); + const char *pos = block_extract_position_id(bcn); + if (pos && old_blocks) { + block_list_add(old_blocks, bval ? bval : "", pos); + } + } + databasevm_finalize(list_vm); + } + cloudsync_memory_free(list_sql); + } + cloudsync_memory_free(like_pattern); + } + } + + // Split new text into parts (NULL text = all blocks removed) + block_list_t *new_blocks = new_text ? block_split(new_text, delim) : block_list_create_empty(); + if (new_blocks && old_blocks) { + // Build array of new content strings (NULL when count is 0) + const char **new_parts = NULL; + if (new_blocks->count > 0) { + new_parts = (const char **)cloudsync_memory_alloc( + (uint64_t)(new_blocks->count * sizeof(char *))); + if (new_parts) { + for (int b = 0; b < new_blocks->count; b++) { + new_parts[b] = new_blocks->entries[b].content; + } + } + } + + if (new_parts || new_blocks->count == 0) { + block_diff_t *diff = block_diff(old_blocks->entries, old_blocks->count, + new_parts, new_blocks->count); + if (diff) { + for (int d = 0; d < diff->count; d++) { + block_diff_entry_t *de = &diff->entries[d]; + char *block_cn = block_build_colname(col, de->position_id); + if (!block_cn) continue; + + if (de->type == BLOCK_DIFF_ADDED || de->type == BLOCK_DIFF_MODIFIED) { + rc = local_mark_insert_or_update_meta(table, pk, pklen, block_cn, + db_version, cloudsync_bumpseq(data)); + // Store block value + if (rc == SQLITE_OK && table_block_value_write_stmt(table)) { + dbvm_t *wvm = table_block_value_write_stmt(table); + databasevm_bind_blob(wvm, 1, pk, (int)pklen); + databasevm_bind_text(wvm, 2, block_cn, -1); + databasevm_bind_text(wvm, 3, de->content, -1); + databasevm_step(wvm); + databasevm_reset(wvm); + } + } else if (de->type == BLOCK_DIFF_REMOVED) { + // Mark block as deleted in metadata (even col_version) + rc = local_mark_delete_block_meta(table, pk, pklen, block_cn, + db_version, cloudsync_bumpseq(data)); + // Remove from blocks table + if (rc == SQLITE_OK) { + block_delete_value_external(data, table, pk, pklen, block_cn); + } + } + cloudsync_memory_free(block_cn); + if (rc != SQLITE_OK) break; + } + block_diff_free(diff); + } + if (new_parts) cloudsync_memory_free((void *)new_parts); + } + } + if (new_blocks) block_list_free(new_blocks); + if (old_blocks) block_list_free(old_blocks); + if (rc != SQLITE_OK) goto cleanup; + } else { + // Regular column: mark as updated in the metadata (columns are in cid order) + rc = local_mark_insert_or_update_meta(table, pk, pklen, table_colname(table, i), db_version, cloudsync_bumpseq(data)); + if (rc != SQLITE_OK) goto cleanup; + } } } @@ -970,6 +1157,62 @@ int dbsync_register_trigger_aggregate (sqlite3 *db, const char *name, void (*xst return dbsync_register_with_flags(db, name, NULL, xstep, xfinal, nargs, FLAGS_TRIGGER, pzErrMsg, ctx, ctx_free); } +// MARK: - Block-level LWW - + +void dbsync_text_materialize (sqlite3_context *context, int argc, sqlite3_value **argv) { + DEBUG_FUNCTION("cloudsync_text_materialize"); + + // argv[0] -> table name + // argv[1] -> column name + // argv[2..N] -> primary key values + + if (argc < 3) { + sqlite3_result_error(context, "cloudsync_text_materialize requires at least 3 arguments: table, column, pk...", -1); + return; + } + + const char *table_name = (const char *)database_value_text(argv[0]); + const char *col_name = (const char *)database_value_text(argv[1]); + cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); + + cloudsync_table_context *table = table_lookup(data, table_name); + if (!table) { + dbsync_set_error(context, "Unable to retrieve table name %s in cloudsync_text_materialize.", table_name); + return; + } + + int col_idx = table_col_index(table, col_name); + if (col_idx < 0 || table_col_algo(table, col_idx) != col_algo_block) { + dbsync_set_error(context, "Column %s in table %s is not configured as block-level.", col_name, table_name); + return; + } + + // Encode primary keys + int npks = table_count_pks(table); + if (argc - 2 != npks) { + sqlite3_result_error(context, "Wrong number of primary key values for cloudsync_text_materialize.", -1); + return; + } + + char buffer[1024]; + size_t pklen = sizeof(buffer); + char *pk = pk_encode_prikey((dbvalue_t **)&argv[2], npks, buffer, &pklen); + if (!pk || pk == PRIKEY_NULL_CONSTRAINT_ERROR) { + sqlite3_result_error(context, "Failed to encode primary key(s).", -1); + return; + } + + // Materialize the column + int rc = block_materialize_column(data, table, pk, (int)pklen, col_name); + if (rc != DBRES_OK) { + sqlite3_result_error(context, cloudsync_errmsg(data), -1); + } else { + sqlite3_result_int(context, 1); + } + + if (pk != buffer) cloudsync_memory_free(pk); +} + // MARK: - Row Filter - void dbsync_set_filter (sqlite3_context *context, int argc, sqlite3_value **argv) { @@ -1043,7 +1286,10 @@ int dbsync_register_functions (sqlite3 *db, char **pzErrMsg) { // init memory debugger (NOOP in production) cloudsync_memory_init(1); - + + // set fractional-indexing allocator to use cloudsync memory + block_init_allocator(); + // init context void *ctx = cloudsync_context_create(db); if (!ctx) { @@ -1169,6 +1415,9 @@ int dbsync_register_functions (sqlite3 *db, char **pzErrMsg) { rc = dbsync_register_function(db, "cloudsync_seq", dbsync_seq, 0, pzErrMsg, ctx, NULL); if (rc != SQLITE_OK) return rc; + rc = dbsync_register_function(db, "cloudsync_text_materialize", dbsync_text_materialize, -1, pzErrMsg, ctx, NULL); + if (rc != SQLITE_OK) return rc; + // NETWORK LAYER #ifndef CLOUDSYNC_OMIT_NETWORK rc = cloudsync_network_register(db, pzErrMsg, ctx); diff --git a/src/sqlite/database_sqlite.c b/src/sqlite/database_sqlite.c index 96a93d0..b7864bb 100644 --- a/src/sqlite/database_sqlite.c +++ b/src/sqlite/database_sqlite.c @@ -318,6 +318,11 @@ char *database_build_base_ref (const char *schema, const char *table_name) { return cloudsync_string_dup(table_name); } +char *database_build_blocks_ref (const char *schema, const char *table_name) { + // schema unused in SQLite + return cloudsync_memory_mprintf("%s_cloudsync_blocks", table_name); +} + // SQLite version: schema parameter unused (SQLite has no schemas). char *sql_build_delete_cols_not_in_schema_query (const char *schema, const char *table_name, const char *meta_ref, const char *pkcol) { UNUSED_PARAMETER(schema); diff --git a/src/sqlite/sql_sqlite.c b/src/sqlite/sql_sqlite.c index 435111f..236a67b 100644 --- a/src/sqlite/sql_sqlite.c +++ b/src/sqlite/sql_sqlite.c @@ -37,7 +37,7 @@ const char * const SQL_SETTINGS_LOAD_GLOBAL = "SELECT key, value FROM cloudsync_settings;"; const char * const SQL_SETTINGS_LOAD_TABLE = - "SELECT lower(tbl_name), lower(col_name), key, value FROM cloudsync_table_settings ORDER BY tbl_name;"; + "SELECT lower(tbl_name), lower(col_name), key, value FROM cloudsync_table_settings ORDER BY tbl_name, col_name;"; const char * const SQL_CREATE_SETTINGS_TABLE = "CREATE TABLE IF NOT EXISTS cloudsync_settings (key TEXT PRIMARY KEY NOT NULL COLLATE NOCASE, value TEXT);"; @@ -276,3 +276,28 @@ const char * const SQL_CLOUDSYNC_SELECT_PKS_NOT_IN_SYNC_FOR_COL_FILTERED = const char * const SQL_CHANGES_INSERT_ROW = "INSERT INTO cloudsync_changes(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq) " "VALUES (?,?,?,?,?,?,?,?,?);"; + +// MARK: Blocks (block-level LWW) + +const char * const SQL_BLOCKS_CREATE_TABLE = + "CREATE TABLE IF NOT EXISTS %s (" + "pk BLOB NOT NULL, " + "col_name TEXT NOT NULL, " + "col_value BLOB, " + "PRIMARY KEY (pk, col_name)) WITHOUT ROWID"; + +const char * const SQL_BLOCKS_UPSERT = + "INSERT OR REPLACE INTO %s (pk, col_name, col_value) VALUES (?1, ?2, ?3)"; + +const char * const SQL_BLOCKS_SELECT = + "SELECT col_value FROM %s WHERE pk = ?1 AND col_name = ?2"; + +const char * const SQL_BLOCKS_DELETE = + "DELETE FROM %s WHERE pk = ?1 AND col_name = ?2"; + +const char * const SQL_BLOCKS_LIST_ALIVE = + "SELECT b.col_value FROM %s b " + "JOIN %s m ON b.pk = m.pk AND b.col_name = m.col_name " + "WHERE b.pk = ?1 AND b.col_name LIKE ?2 " + "AND m.pk = ?3 AND m.col_name LIKE ?4 AND m.col_version %% 2 = 1 " + "ORDER BY b.col_name"; diff --git a/test/postgresql/32_block_lww.sql b/test/postgresql/32_block_lww.sql new file mode 100644 index 0000000..00dbf37 --- /dev/null +++ b/test/postgresql/32_block_lww.sql @@ -0,0 +1,146 @@ +-- 'Block-level LWW test' + +\set testid '32' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_test_a; +CREATE DATABASE cloudsync_block_test_a; + +\connect cloudsync_block_test_a +\ir helper_psql_conn_setup.sql + +CREATE EXTENSION IF NOT EXISTS cloudsync; + +-- Create a table with a text column for block-level LWW +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); + +-- Initialize cloudsync for the table +SELECT cloudsync_init('docs', 'CLS', true) AS _init \gset + +-- Configure body column as block-level +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol \gset + +-- Test 1: INSERT text, verify blocks table populated +INSERT INTO docs (id, body) VALUES ('doc1', 'line1 +line2 +line3'); + +-- Verify blocks table was created +SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = 'docs_cloudsync_blocks') AS blocks_table_exists \gset +\if :blocks_table_exists +\echo [PASS] (:testid) Blocks table created +\else +\echo [FAIL] (:testid) Blocks table not created +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify blocks have been stored (3 lines = 3 blocks) +SELECT count(*) AS block_count FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:block_count::int = 3) AS insert_blocks_ok \gset +\if :insert_blocks_ok +\echo [PASS] (:testid) Block insert: 3 blocks created +\else +\echo [FAIL] (:testid) Block insert: expected 3 blocks, got :block_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify metadata has block entries (col_name contains \x1F separator) +SELECT count(*) AS meta_block_count FROM docs_cloudsync WHERE col_name LIKE 'body' || chr(31) || '%' \gset +SELECT (:meta_block_count::int = 3) AS meta_blocks_ok \gset +\if :meta_blocks_ok +\echo [PASS] (:testid) Block metadata: 3 block entries in _cloudsync +\else +\echo [FAIL] (:testid) Block metadata: expected 3 entries, got :meta_block_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 2: UPDATE text (modify one line, add one line) +UPDATE docs SET body = 'line1 +line2_modified +line3 +line4' WHERE id = 'doc1'; + +-- Verify blocks updated (should now have 4 blocks) +SELECT count(*) AS block_count2 FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:block_count2::int = 4) AS update_blocks_ok \gset +\if :update_blocks_ok +\echo [PASS] (:testid) Block update: 4 blocks after update +\else +\echo [FAIL] (:testid) Block update: expected 4 blocks, got :block_count2 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 3: Materialize and verify round-trip +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat \gset +SELECT body AS materialized_body FROM docs WHERE id = 'doc1' \gset + +SELECT (:'materialized_body' = 'line1 +line2_modified +line3 +line4') AS materialize_ok \gset +\if :materialize_ok +\echo [PASS] (:testid) Text materialize: reconstructed text matches +\else +\echo [FAIL] (:testid) Text materialize: text mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 4: Verify col_value works for block entries +SELECT count(*) AS col_value_count FROM docs_cloudsync +WHERE col_name LIKE 'body' || chr(31) || '%' +AND cloudsync_col_value('docs', col_name, pk) IS NOT NULL \gset +SELECT (:col_value_count::int > 0) AS col_value_ok \gset +\if :col_value_ok +\echo [PASS] (:testid) col_value works for block entries +\else +\echo [FAIL] (:testid) col_value returned NULL for block entries +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Test 5: Sync roundtrip - encode payload from db A before disconnecting +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS block_payload_hex +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_test_b; +CREATE DATABASE cloudsync_block_test_b; +\connect cloudsync_block_test_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_b \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_b \gset + +SELECT cloudsync_payload_apply(decode(:'block_payload_hex', 'hex')) AS _apply_b \gset + +-- Materialize on db B +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat_b \gset +SELECT body AS body_b FROM docs WHERE id = 'doc1' \gset + +SELECT (:'body_b' = 'line1 +line2_modified +line3 +line4') AS sync_ok \gset +\if :sync_ok +\echo [PASS] (:testid) Block sync roundtrip: text matches after apply + materialize +\else +\echo [FAIL] (:testid) Block sync roundtrip: text mismatch on db B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_block_test_a; +DROP DATABASE IF EXISTS cloudsync_block_test_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/33_block_lww_extended.sql b/test/postgresql/33_block_lww_extended.sql new file mode 100644 index 0000000..6b11338 --- /dev/null +++ b/test/postgresql/33_block_lww_extended.sql @@ -0,0 +1,339 @@ +-- 'Block-level LWW extended tests: DELETE, empty text, multi-update, conflict' + +\set testid '33' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_ext_a; +DROP DATABASE IF EXISTS cloudsync_block_ext_b; +CREATE DATABASE cloudsync_block_ext_a; +CREATE DATABASE cloudsync_block_ext_b; + +-- ============================================================ +-- Setup db A +-- ============================================================ +\connect cloudsync_block_ext_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_a \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_a \gset + +-- ============================================================ +-- Test 1: DELETE marks tombstone, block metadata dropped +-- ============================================================ +INSERT INTO docs (id, body) VALUES ('doc1', 'line1 +line2 +line3'); + +-- Verify 3 block metadata entries exist +SELECT count(*) AS meta_before FROM docs_cloudsync WHERE col_name LIKE 'body' || chr(31) || '%' \gset +SELECT (:meta_before::int = 3) AS meta_before_ok \gset +\if :meta_before_ok +\echo [PASS] (:testid) Delete pre-check: 3 block metadata entries +\else +\echo [FAIL] (:testid) Delete pre-check: expected 3 metadata, got :meta_before +SELECT (:fail::int + 1) AS fail \gset +\endif + +DELETE FROM docs WHERE id = 'doc1'; + +-- Tombstone should exist with even version (deleted) +SELECT count(*) AS tombstone_count FROM docs_cloudsync WHERE col_name = '__[RIP]__' AND col_version % 2 = 0 \gset +SELECT (:tombstone_count::int = 1) AS tombstone_ok \gset +\if :tombstone_ok +\echo [PASS] (:testid) Delete: tombstone exists with even version +\else +\echo [FAIL] (:testid) Delete: expected 1 tombstone, got :tombstone_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Block metadata should be dropped +SELECT count(*) AS meta_after FROM docs_cloudsync WHERE col_name LIKE 'body' || chr(31) || '%' \gset +SELECT (:meta_after::int = 0) AS meta_dropped_ok \gset +\if :meta_dropped_ok +\echo [PASS] (:testid) Delete: block metadata dropped +\else +\echo [FAIL] (:testid) Delete: expected 0 metadata after delete, got :meta_after +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Row should be gone from base table +SELECT count(*) AS row_after FROM docs WHERE id = 'doc1' \gset +SELECT (:row_after::int = 0) AS row_gone_ok \gset +\if :row_gone_ok +\echo [PASS] (:testid) Delete: row removed from base table +\else +\echo [FAIL] (:testid) Delete: row still in base table +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Empty text creates single block +-- ============================================================ +INSERT INTO docs (id, body) VALUES ('doc_empty', ''); + +SELECT count(*) AS empty_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_empty') \gset +SELECT (:empty_blocks::int = 1) AS empty_block_ok \gset +\if :empty_block_ok +\echo [PASS] (:testid) Empty text: 1 block created +\else +\echo [FAIL] (:testid) Empty text: expected 1 block, got :empty_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update from empty to multi-line +UPDATE docs SET body = 'NewLine1 +NewLine2' WHERE id = 'doc_empty'; + +SELECT count(*) AS updated_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_empty') \gset +SELECT (:updated_blocks::int = 2) AS update_from_empty_ok \gset +\if :update_from_empty_ok +\echo [PASS] (:testid) Empty text: 2 blocks after update +\else +\echo [FAIL] (:testid) Empty text: expected 2 blocks after update, got :updated_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: Multi-update block counts +-- ============================================================ +INSERT INTO docs (id, body) VALUES ('doc_multi', 'A +B +C'); + +-- Update 1: remove middle line +UPDATE docs SET body = 'A +C' WHERE id = 'doc_multi'; + +SELECT count(*) AS blocks1 FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_multi') \gset +SELECT (:blocks1::int = 2) AS multi1_ok \gset +\if :multi1_ok +\echo [PASS] (:testid) Multi-update: 2 blocks after removing middle +\else +\echo [FAIL] (:testid) Multi-update: expected 2, got :blocks1 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update 2: add two lines +UPDATE docs SET body = 'A +X +C +Y' WHERE id = 'doc_multi'; + +SELECT count(*) AS blocks2 FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_multi') \gset +SELECT (:blocks2::int = 4) AS multi2_ok \gset +\if :multi2_ok +\echo [PASS] (:testid) Multi-update: 4 blocks after adding lines +\else +\echo [FAIL] (:testid) Multi-update: expected 4, got :blocks2 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update 3: collapse to single line +UPDATE docs SET body = 'SINGLE' WHERE id = 'doc_multi'; + +SELECT count(*) AS blocks3 FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_multi') \gset +SELECT (:blocks3::int = 1) AS multi3_ok \gset +\if :multi3_ok +\echo [PASS] (:testid) Multi-update: 1 block after collapse +\else +\echo [FAIL] (:testid) Multi-update: expected 1, got :blocks3 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Materialize and verify +SELECT cloudsync_text_materialize('docs', 'body', 'doc_multi') AS _mat_multi \gset +SELECT body AS multi_body FROM docs WHERE id = 'doc_multi' \gset +SELECT (:'multi_body' = 'SINGLE') AS multi_mat_ok \gset +\if :multi_mat_ok +\echo [PASS] (:testid) Multi-update: materialize matches +\else +\echo [FAIL] (:testid) Multi-update: materialize mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Two-database conflict on same block +-- ============================================================ + +-- Setup db B +\connect cloudsync_block_ext_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_b \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_b \gset + +-- Insert initial doc on db A +\connect cloudsync_block_ext_a +INSERT INTO docs (id, body) VALUES ('doc_conflict', 'Same +Middle +End'); + +-- Sync A -> B (round 1) +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_a_r1 +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_ext_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_a_r1', 'hex')) AS _apply_b_r1 \gset + +-- Materialize on B to get body +SELECT cloudsync_text_materialize('docs', 'body', 'doc_conflict') AS _mat_b_init \gset + +-- Verify B has the initial doc +SELECT body AS body_b_init FROM docs WHERE id = 'doc_conflict' \gset +SELECT (:'body_b_init' = 'Same +Middle +End') AS init_sync_ok \gset +\if :init_sync_ok +\echo [PASS] (:testid) Conflict: initial sync to B matches +\else +\echo [FAIL] (:testid) Conflict: initial sync to B mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Site A edits first line +\connect cloudsync_block_ext_a +UPDATE docs SET body = 'SiteA +Middle +End' WHERE id = 'doc_conflict'; + +-- Site B edits first line (conflict!) +\connect cloudsync_block_ext_b +UPDATE docs SET body = 'SiteB +Middle +End' WHERE id = 'doc_conflict'; + +-- Collect payloads from both sites +\connect cloudsync_block_ext_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_a_r2 +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_ext_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_b_r2 +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +-- Apply A's changes to B +SELECT cloudsync_payload_apply(decode(:'payload_a_r2', 'hex')) AS _apply_b_r2 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_conflict') AS _mat_b_r2 \gset + +-- Apply B's changes to A +\connect cloudsync_block_ext_a +SELECT cloudsync_payload_apply(decode(:'payload_b_r2', 'hex')) AS _apply_a_r2 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_conflict') AS _mat_a_r2 \gset + +-- Both should converge +SELECT body AS body_a_final FROM docs WHERE id = 'doc_conflict' \gset + +\connect cloudsync_block_ext_b +SELECT body AS body_b_final FROM docs WHERE id = 'doc_conflict' \gset + +-- Bodies must match (convergence) +SELECT (:'body_a_final' = :'body_b_final') AS converge_ok \gset +\if :converge_ok +\echo [PASS] (:testid) Conflict: databases converge after sync +\else +\echo [FAIL] (:testid) Conflict: databases diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Unchanged lines must be preserved +SELECT (position('Middle' in :'body_a_final') > 0) AS has_middle \gset +\if :has_middle +\echo [PASS] (:testid) Conflict: unchanged line 'Middle' preserved +\else +\echo [FAIL] (:testid) Conflict: 'Middle' missing from result +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('End' in :'body_a_final') > 0) AS has_end \gset +\if :has_end +\echo [PASS] (:testid) Conflict: unchanged line 'End' preserved +\else +\echo [FAIL] (:testid) Conflict: 'End' missing from result +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- One of the conflicting edits must win +SELECT (position('SiteA' in :'body_a_final') > 0 OR position('SiteB' in :'body_a_final') > 0) AS has_winner \gset +\if :has_winner +\echo [PASS] (:testid) Conflict: one site edit won (LWW) +\else +\echo [FAIL] (:testid) Conflict: neither SiteA nor SiteB in result +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: DELETE then re-INSERT (reinsert) +-- ============================================================ +\connect cloudsync_block_ext_a + +INSERT INTO docs (id, body) VALUES ('doc_reinsert', 'Old1 +Old2'); +DELETE FROM docs WHERE id = 'doc_reinsert'; + +-- Block metadata should be dropped after delete +SELECT count(*) AS meta_reinsert_del FROM docs_cloudsync +WHERE pk = cloudsync_pk_encode('doc_reinsert') +AND col_name LIKE 'body' || chr(31) || '%' \gset +SELECT (:meta_reinsert_del::int = 0) AS reinsert_meta_del_ok \gset +\if :reinsert_meta_del_ok +\echo [PASS] (:testid) Reinsert: metadata dropped after delete +\else +\echo [FAIL] (:testid) Reinsert: expected 0 metadata, got :meta_reinsert_del +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Re-insert with new content +INSERT INTO docs (id, body) VALUES ('doc_reinsert', 'New1 +New2 +New3'); + +SELECT count(*) AS meta_reinsert_new FROM docs_cloudsync +WHERE pk = cloudsync_pk_encode('doc_reinsert') +AND col_name LIKE 'body' || chr(31) || '%' \gset +SELECT (:meta_reinsert_new::int = 3) AS reinsert_meta_ok \gset +\if :reinsert_meta_ok +\echo [PASS] (:testid) Reinsert: 3 block metadata after re-insert +\else +\echo [FAIL] (:testid) Reinsert: expected 3 metadata, got :meta_reinsert_new +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync to B and materialize +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_reinsert +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_ext_b +SELECT cloudsync_payload_apply(decode(:'payload_reinsert', 'hex')) AS _apply_reinsert \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_reinsert') AS _mat_reinsert \gset +SELECT body AS body_reinsert FROM docs WHERE id = 'doc_reinsert' \gset + +SELECT (:'body_reinsert' = 'New1 +New2 +New3') AS reinsert_sync_ok \gset +\if :reinsert_sync_ok +\echo [PASS] (:testid) Reinsert: sync roundtrip matches +\else +\echo [FAIL] (:testid) Reinsert: sync mismatch on db B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_block_ext_a; +DROP DATABASE IF EXISTS cloudsync_block_ext_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/34_block_lww_advanced.sql b/test/postgresql/34_block_lww_advanced.sql new file mode 100644 index 0000000..ea40e8a --- /dev/null +++ b/test/postgresql/34_block_lww_advanced.sql @@ -0,0 +1,698 @@ +-- 'Block-level LWW advanced tests: noconflict, add+edit, three-way, mixed cols, NULL->text, interleaved, custom delimiter, large text, rapid updates' + +\set testid '34' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_adv_a; +DROP DATABASE IF EXISTS cloudsync_block_adv_b; +DROP DATABASE IF EXISTS cloudsync_block_adv_c; +CREATE DATABASE cloudsync_block_adv_a; +CREATE DATABASE cloudsync_block_adv_b; +CREATE DATABASE cloudsync_block_adv_c; + +-- ============================================================ +-- Test 1: Non-conflicting edits on different blocks +-- Site A edits line 1, Site B edits line 3 — BOTH should survive +-- ============================================================ +\connect cloudsync_block_adv_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_a \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_a \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_b \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_b \gset + +-- Insert initial on A +\connect cloudsync_block_adv_a +INSERT INTO docs (id, body) VALUES ('doc1', 'Line1 +Line2 +Line3'); + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_init +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_init', 'hex')) AS _apply_init \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat_init \gset + +-- Site A: edit first line +\connect cloudsync_block_adv_a +UPDATE docs SET body = 'EditedByA +Line2 +Line3' WHERE id = 'doc1'; + +-- Site B: edit third line (no conflict — different block) +\connect cloudsync_block_adv_b +UPDATE docs SET body = 'Line1 +Line2 +EditedByB' WHERE id = 'doc1'; + +-- Collect payloads +\connect cloudsync_block_adv_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +-- Apply A -> B, B -> A +SELECT cloudsync_payload_apply(decode(:'payload_a', 'hex')) AS _apply_ab \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat_b \gset + +\connect cloudsync_block_adv_a +SELECT cloudsync_payload_apply(decode(:'payload_b', 'hex')) AS _apply_ba \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat_a \gset + +-- Both should converge +SELECT body AS body_a FROM docs WHERE id = 'doc1' \gset +\connect cloudsync_block_adv_b +SELECT body AS body_b FROM docs WHERE id = 'doc1' \gset + +SELECT (:'body_a' = :'body_b') AS converge_ok \gset +\if :converge_ok +\echo [PASS] (:testid) NoConflict: databases converge +\else +\echo [FAIL] (:testid) NoConflict: databases diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Both edits should be preserved +SELECT (position('EditedByA' in :'body_a') > 0) AS has_a_edit \gset +\if :has_a_edit +\echo [PASS] (:testid) NoConflict: Site A edit preserved +\else +\echo [FAIL] (:testid) NoConflict: Site A edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('EditedByB' in :'body_a') > 0) AS has_b_edit \gset +\if :has_b_edit +\echo [PASS] (:testid) NoConflict: Site B edit preserved +\else +\echo [FAIL] (:testid) NoConflict: Site B edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('Line2' in :'body_a') > 0) AS has_middle \gset +\if :has_middle +\echo [PASS] (:testid) NoConflict: unchanged line preserved +\else +\echo [FAIL] (:testid) NoConflict: unchanged line missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Concurrent add + edit +-- Site A adds a line, Site B modifies an existing line +-- ============================================================ +\connect cloudsync_block_adv_a +INSERT INTO docs (id, body) VALUES ('doc2', 'Alpha +Bravo'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_d2_init +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_d2_init', 'hex')) AS _apply_d2 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc2') AS _mat_d2 \gset + +-- Site A: add a new line at end +\connect cloudsync_block_adv_a +UPDATE docs SET body = 'Alpha +Bravo +Charlie' WHERE id = 'doc2'; + +-- Site B: modify first line +\connect cloudsync_block_adv_b +UPDATE docs SET body = 'AlphaEdited +Bravo' WHERE id = 'doc2'; + +\connect cloudsync_block_adv_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_d2a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_d2b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +SELECT cloudsync_payload_apply(decode(:'payload_d2a', 'hex')) AS _apply_d2ab \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc2') AS _mat_d2b \gset + +\connect cloudsync_block_adv_a +SELECT cloudsync_payload_apply(decode(:'payload_d2b', 'hex')) AS _apply_d2ba \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc2') AS _mat_d2a \gset + +SELECT body AS body_d2a FROM docs WHERE id = 'doc2' \gset +\connect cloudsync_block_adv_b +SELECT body AS body_d2b FROM docs WHERE id = 'doc2' \gset + +SELECT (:'body_d2a' = :'body_d2b') AS d2_converge \gset +\if :d2_converge +\echo [PASS] (:testid) Add+Edit: databases converge +\else +\echo [FAIL] (:testid) Add+Edit: databases diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('Charlie' in :'body_d2a') > 0) AS has_charlie \gset +\if :has_charlie +\echo [PASS] (:testid) Add+Edit: added line Charlie preserved +\else +\echo [FAIL] (:testid) Add+Edit: added line Charlie missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('Bravo' in :'body_d2a') > 0) AS has_bravo \gset +\if :has_bravo +\echo [PASS] (:testid) Add+Edit: unchanged Bravo preserved +\else +\echo [FAIL] (:testid) Add+Edit: Bravo missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: Three-way sync — 3 databases, each edits a different line +-- ============================================================ +\connect cloudsync_block_adv_c +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init_c \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _setcol_c \gset + +-- Insert initial on A +\connect cloudsync_block_adv_a +INSERT INTO docs (id, body) VALUES ('doc3', 'L1 +L2 +L3 +L4'); + +-- Sync A -> B, A -> C +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3init +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3init', 'hex')) AS _apply_3b \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat_3b \gset + +\connect cloudsync_block_adv_c +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3init', 'hex')) AS _apply_3c \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat_3c \gset + +-- A edits line 1 +\connect cloudsync_block_adv_a +UPDATE docs SET body = 'S0 +L2 +L3 +L4' WHERE id = 'doc3'; + +-- B edits line 2 +\connect cloudsync_block_adv_b +UPDATE docs SET body = 'L1 +S1 +L3 +L4' WHERE id = 'doc3'; + +-- C edits line 4 +\connect cloudsync_block_adv_c +UPDATE docs SET body = 'L1 +L2 +L3 +S2' WHERE id = 'doc3'; + +-- Collect all payloads +\connect cloudsync_block_adv_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_c +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3c +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +-- Full mesh apply: each site receives from the other two +\connect cloudsync_block_adv_a +SELECT cloudsync_payload_apply(decode(:'payload_3b', 'hex')) AS _3ab \gset +SELECT cloudsync_payload_apply(decode(:'payload_3c', 'hex')) AS _3ac \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat_3a_final \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3a', 'hex')) AS _3ba \gset +SELECT cloudsync_payload_apply(decode(:'payload_3c', 'hex')) AS _3bc \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat_3b_final \gset + +\connect cloudsync_block_adv_c +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3a', 'hex')) AS _3ca \gset +SELECT cloudsync_payload_apply(decode(:'payload_3b', 'hex')) AS _3cb \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat_3c_final \gset + +-- All three should converge +\connect cloudsync_block_adv_a +SELECT body AS body_3a FROM docs WHERE id = 'doc3' \gset +\connect cloudsync_block_adv_b +SELECT body AS body_3b FROM docs WHERE id = 'doc3' \gset +\connect cloudsync_block_adv_c +SELECT body AS body_3c FROM docs WHERE id = 'doc3' \gset + +SELECT (:'body_3a' = :'body_3b' AND :'body_3b' = :'body_3c') AS three_converge \gset +\if :three_converge +\echo [PASS] (:testid) Three-way: all 3 databases converge +\else +\echo [FAIL] (:testid) Three-way: databases diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('S0' in :'body_3a') > 0) AS has_s0 \gset +\if :has_s0 +\echo [PASS] (:testid) Three-way: Site A edit preserved +\else +\echo [FAIL] (:testid) Three-way: Site A edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('S1' in :'body_3a') > 0) AS has_s1 \gset +\if :has_s1 +\echo [PASS] (:testid) Three-way: Site B edit preserved +\else +\echo [FAIL] (:testid) Three-way: Site B edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('S2' in :'body_3a') > 0) AS has_s2 \gset +\if :has_s2 +\echo [PASS] (:testid) Three-way: Site C edit preserved +\else +\echo [FAIL] (:testid) Three-way: Site C edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Mixed block + normal columns +-- ============================================================ +\connect cloudsync_block_adv_a +DROP TABLE IF EXISTS notes; +CREATE TABLE notes (id TEXT PRIMARY KEY NOT NULL, body TEXT, title TEXT); +SELECT cloudsync_init('notes', 'CLS', true) AS _init_notes_a \gset +SELECT cloudsync_set_column('notes', 'body', 'algo', 'block') AS _setcol_notes_a \gset + +\connect cloudsync_block_adv_b +DROP TABLE IF EXISTS notes; +CREATE TABLE notes (id TEXT PRIMARY KEY NOT NULL, body TEXT, title TEXT); +SELECT cloudsync_init('notes', 'CLS', true) AS _init_notes_b \gset +SELECT cloudsync_set_column('notes', 'body', 'algo', 'block') AS _setcol_notes_b \gset + +\connect cloudsync_block_adv_a +INSERT INTO notes (id, body, title) VALUES ('n1', 'Line1 +Line2 +Line3', 'My Title'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_notes_init +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'notes' \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_notes_init', 'hex')) AS _apply_notes \gset +SELECT cloudsync_text_materialize('notes', 'body', 'n1') AS _mat_notes \gset + +-- A: edit block line 1 + title +\connect cloudsync_block_adv_a +UPDATE notes SET body = 'EditedLine1 +Line2 +Line3', title = 'Title From A' WHERE id = 'n1'; + +-- B: edit block line 3 + title (title conflicts via normal LWW) +\connect cloudsync_block_adv_b +UPDATE notes SET body = 'Line1 +Line2 +EditedLine3', title = 'Title From B' WHERE id = 'n1'; + +\connect cloudsync_block_adv_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_notes_a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'notes' \gset + +\connect cloudsync_block_adv_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_notes_b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'notes' \gset + +SELECT cloudsync_payload_apply(decode(:'payload_notes_a', 'hex')) AS _apply_notes_ab \gset +SELECT cloudsync_text_materialize('notes', 'body', 'n1') AS _mat_notes_b \gset + +\connect cloudsync_block_adv_a +SELECT cloudsync_payload_apply(decode(:'payload_notes_b', 'hex')) AS _apply_notes_ba \gset +SELECT cloudsync_text_materialize('notes', 'body', 'n1') AS _mat_notes_a \gset + +SELECT body AS notes_body_a FROM notes WHERE id = 'n1' \gset +SELECT title AS notes_title_a FROM notes WHERE id = 'n1' \gset +\connect cloudsync_block_adv_b +SELECT body AS notes_body_b FROM notes WHERE id = 'n1' \gset +SELECT title AS notes_title_b FROM notes WHERE id = 'n1' \gset + +SELECT (:'notes_body_a' = :'notes_body_b') AS mixed_body_ok \gset +\if :mixed_body_ok +\echo [PASS] (:testid) MixedCols: body converges +\else +\echo [FAIL] (:testid) MixedCols: body diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('EditedLine1' in :'notes_body_a') > 0 AND position('EditedLine3' in :'notes_body_a') > 0) AS both_edits \gset +\if :both_edits +\echo [PASS] (:testid) MixedCols: both block edits preserved +\else +\echo [FAIL] (:testid) MixedCols: block edits missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (:'notes_title_a' = :'notes_title_b') AS mixed_title_ok \gset +\if :mixed_title_ok +\echo [PASS] (:testid) MixedCols: title converges (normal LWW) +\else +\echo [FAIL] (:testid) MixedCols: title diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: NULL to text transition +-- ============================================================ +\connect cloudsync_block_adv_a +INSERT INTO docs (id, body) VALUES ('doc_null', NULL); + +-- Verify 1 block for NULL +SELECT count(*) AS null_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_null') \gset +SELECT (:null_blocks::int = 1) AS null_block_ok \gset +\if :null_block_ok +\echo [PASS] (:testid) NULL->Text: 1 block for NULL body +\else +\echo [FAIL] (:testid) NULL->Text: expected 1 block, got :null_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update to multi-line +UPDATE docs SET body = 'Hello +World +Foo' WHERE id = 'doc_null'; + +SELECT count(*) AS text_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_null') \gset +SELECT (:text_blocks::int = 3) AS text_block_ok \gset +\if :text_block_ok +\echo [PASS] (:testid) NULL->Text: 3 blocks after update +\else +\echo [FAIL] (:testid) NULL->Text: expected 3 blocks, got :text_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync and verify +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_null +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_null', 'hex')) AS _apply_null \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_null') AS _mat_null \gset + +SELECT body AS body_null FROM docs WHERE id = 'doc_null' \gset +SELECT (:'body_null' = 'Hello +World +Foo') AS null_text_ok \gset +\if :null_text_ok +\echo [PASS] (:testid) NULL->Text: sync roundtrip matches +\else +\echo [FAIL] (:testid) NULL->Text: sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 6: Interleaved inserts — multiple rounds between existing lines +-- ============================================================ +\connect cloudsync_block_adv_a +INSERT INTO docs (id, body) VALUES ('doc_inter', 'A +B'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_inter_init +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_inter_init', 'hex')) AS _apply_inter \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_inter') AS _mat_inter \gset + +-- Round 1: A inserts between A and B +\connect cloudsync_block_adv_a +UPDATE docs SET body = 'A +C +B' WHERE id = 'doc_inter'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_inter_r1 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_inter_r1', 'hex')) AS _r1 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_inter') AS _mat_r1 \gset + +-- Round 2: B inserts between A and C +\connect cloudsync_block_adv_b +UPDATE docs SET body = 'A +D +C +B' WHERE id = 'doc_inter'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_inter_r2 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset +\connect cloudsync_block_adv_a +SELECT cloudsync_payload_apply(decode(:'payload_inter_r2', 'hex')) AS _r2 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_inter') AS _mat_r2 \gset + +-- Round 3: A inserts between D and C +\connect cloudsync_block_adv_a +UPDATE docs SET body = 'A +D +E +C +B' WHERE id = 'doc_inter'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_inter_r3 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_inter_r3', 'hex')) AS _r3 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc_inter') AS _mat_r3 \gset + +\connect cloudsync_block_adv_a +SELECT body AS inter_body_a FROM docs WHERE id = 'doc_inter' \gset +\connect cloudsync_block_adv_b +SELECT body AS inter_body_b FROM docs WHERE id = 'doc_inter' \gset + +SELECT (:'inter_body_a' = :'inter_body_b') AS inter_converge \gset +\if :inter_converge +\echo [PASS] (:testid) Interleaved: databases converge +\else +\echo [FAIL] (:testid) Interleaved: databases diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT count(*) AS inter_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc_inter') \gset +SELECT (:inter_blocks::int = 5) AS inter_count_ok \gset +\if :inter_count_ok +\echo [PASS] (:testid) Interleaved: 5 blocks after 3 rounds +\else +\echo [FAIL] (:testid) Interleaved: expected 5 blocks, got :inter_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 7: Custom delimiter (paragraph separator: double newline) +-- ============================================================ +\connect cloudsync_block_adv_a +DROP TABLE IF EXISTS paragraphs; +CREATE TABLE paragraphs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('paragraphs', 'CLS', true) AS _init_para \gset +SELECT cloudsync_set_column('paragraphs', 'body', 'algo', 'block') AS _setcol_para \gset +SELECT cloudsync_set_column('paragraphs', 'body', 'delimiter', E'\n\n') AS _setdelim \gset + +\connect cloudsync_block_adv_b +DROP TABLE IF EXISTS paragraphs; +CREATE TABLE paragraphs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('paragraphs', 'CLS', true) AS _init_para_b \gset +SELECT cloudsync_set_column('paragraphs', 'body', 'algo', 'block') AS _setcol_para_b \gset +SELECT cloudsync_set_column('paragraphs', 'body', 'delimiter', E'\n\n') AS _setdelim_b \gset + +\connect cloudsync_block_adv_a +INSERT INTO paragraphs (id, body) VALUES ('p1', E'Para one line1\nline2\n\nPara two\n\nPara three'); + +-- Should produce 3 blocks (3 paragraphs) +SELECT count(*) AS para_blocks FROM paragraphs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('p1') \gset +SELECT (:para_blocks::int = 3) AS para_ok \gset +\if :para_ok +\echo [PASS] (:testid) CustomDelim: 3 paragraph blocks +\else +\echo [FAIL] (:testid) CustomDelim: expected 3 blocks, got :para_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync and verify roundtrip +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_para +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'paragraphs' \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_para', 'hex')) AS _apply_para \gset +SELECT cloudsync_text_materialize('paragraphs', 'body', 'p1') AS _mat_para \gset + +SELECT body AS para_body FROM paragraphs WHERE id = 'p1' \gset +SELECT (:'para_body' = E'Para one line1\nline2\n\nPara two\n\nPara three') AS para_roundtrip \gset +\if :para_roundtrip +\echo [PASS] (:testid) CustomDelim: sync roundtrip matches +\else +\echo [FAIL] (:testid) CustomDelim: sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 8: Large text — 200 lines +-- ============================================================ +\connect cloudsync_block_adv_a +\ir helper_psql_conn_setup.sql +INSERT INTO docs (id, body) +SELECT 'bigdoc', string_agg('Line ' || lpad(i::text, 3, '0') || ' content', E'\n' ORDER BY i) +FROM generate_series(0, 199) AS s(i); + +SELECT count(*) AS big_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('bigdoc') \gset +SELECT (:big_blocks::int = 200) AS big_ok \gset +\if :big_ok +\echo [PASS] (:testid) LargeText: 200 blocks created +\else +\echo [FAIL] (:testid) LargeText: expected 200 blocks, got :big_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- All positions unique +SELECT count(DISTINCT col_name) AS big_distinct FROM docs_cloudsync +WHERE col_name LIKE 'body' || chr(31) || '%' +AND pk = cloudsync_pk_encode('bigdoc') \gset +SELECT (:big_distinct::int = 200) AS big_unique \gset +\if :big_unique +\echo [PASS] (:testid) LargeText: 200 unique position IDs +\else +\echo [FAIL] (:testid) LargeText: expected 200 unique positions, got :big_distinct +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync and verify +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_big +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_big', 'hex')) AS _apply_big \gset +SELECT cloudsync_text_materialize('docs', 'body', 'bigdoc') AS _mat_big \gset + +SELECT body AS big_body_b FROM docs WHERE id = 'bigdoc' \gset +\connect cloudsync_block_adv_a +SELECT body AS big_body_a FROM docs WHERE id = 'bigdoc' \gset + +SELECT (:'big_body_a' = :'big_body_b') AS big_match \gset +\if :big_match +\echo [PASS] (:testid) LargeText: sync roundtrip matches +\else +\echo [FAIL] (:testid) LargeText: sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 9: Rapid sequential updates — 50 updates on same row +-- ============================================================ +\connect cloudsync_block_adv_a +\ir helper_psql_conn_setup.sql +INSERT INTO docs (id, body) VALUES ('rapid', 'Start'); + +DO $$ +DECLARE + i INT; + new_body TEXT := ''; +BEGIN + FOR i IN 0..49 LOOP + IF i > 0 THEN new_body := new_body || E'\n'; END IF; + new_body := new_body || 'Update' || i; + UPDATE docs SET body = new_body WHERE id = 'rapid'; + END LOOP; +END $$; + +SELECT count(*) AS rapid_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('rapid') \gset +SELECT (:rapid_blocks::int = 50) AS rapid_ok \gset +\if :rapid_ok +\echo [PASS] (:testid) RapidUpdates: 50 blocks after 50 updates +\else +\echo [FAIL] (:testid) RapidUpdates: expected 50 blocks, got :rapid_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync and verify +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_rapid +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_adv_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_rapid', 'hex')) AS _apply_rapid \gset +SELECT cloudsync_text_materialize('docs', 'body', 'rapid') AS _mat_rapid \gset + +SELECT body AS rapid_body_b FROM docs WHERE id = 'rapid' \gset +\connect cloudsync_block_adv_a +SELECT body AS rapid_body_a FROM docs WHERE id = 'rapid' \gset + +SELECT (:'rapid_body_a' = :'rapid_body_b') AS rapid_match \gset +\if :rapid_match +\echo [PASS] (:testid) RapidUpdates: sync roundtrip matches +\else +\echo [FAIL] (:testid) RapidUpdates: sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('Update0' in :'rapid_body_a') > 0) AS has_first \gset +\if :has_first +\echo [PASS] (:testid) RapidUpdates: first update present +\else +\echo [FAIL] (:testid) RapidUpdates: first update missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (position('Update49' in :'rapid_body_a') > 0) AS has_last \gset +\if :has_last +\echo [PASS] (:testid) RapidUpdates: last update present +\else +\echo [FAIL] (:testid) RapidUpdates: last update missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_block_adv_a; +DROP DATABASE IF EXISTS cloudsync_block_adv_b; +DROP DATABASE IF EXISTS cloudsync_block_adv_c; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/35_block_lww_edge_cases.sql b/test/postgresql/35_block_lww_edge_cases.sql new file mode 100644 index 0000000..4692994 --- /dev/null +++ b/test/postgresql/35_block_lww_edge_cases.sql @@ -0,0 +1,420 @@ +-- 'Block-level LWW edge cases: unicode, special chars, delete vs edit, two block cols, text->NULL, payload sync, idempotent, ordering' + +\set testid '35' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_edge_a; +DROP DATABASE IF EXISTS cloudsync_block_edge_b; +CREATE DATABASE cloudsync_block_edge_a; +CREATE DATABASE cloudsync_block_edge_b; + +-- ============================================================ +-- Test 1: Unicode / multibyte content (emoji, CJK, accented) +-- ============================================================ +\connect cloudsync_block_edge_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _sc \gset + +-- Insert unicode text on A +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc1', E'Hello \U0001F600\nBonjour caf\u00e9\n\u65e5\u672c\u8a9e\u30c6\u30b9\u30c8'); + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload1 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload1', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc1') AS _mat \gset + +SELECT (body LIKE E'Hello %') AS unicode_ok FROM docs WHERE id = 'doc1' \gset +\if :unicode_ok +\echo [PASS] (:testid) Unicode: body starts with Hello +\else +\echo [FAIL] (:testid) Unicode: body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Check line count (3 lines = 2 newlines) +SELECT (length(body) - length(replace(body, E'\n', '')) = 2) AS unicode_lines FROM docs WHERE id = 'doc1' \gset +\if :unicode_lines +\echo [PASS] (:testid) Unicode: 3 lines present +\else +\echo [FAIL] (:testid) Unicode: wrong line count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Special characters (tabs, backslashes, quotes) +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc2', E'line\twith\ttabs\nback\\\\slash\nO''Brien said "hi"'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload2 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc2') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload2', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc2') AS _mat \gset + +SELECT (body LIKE E'%\t%') AS special_tabs FROM docs WHERE id = 'doc2' \gset +\if :special_tabs +\echo [PASS] (:testid) SpecialChars: tabs preserved +\else +\echo [FAIL] (:testid) SpecialChars: tabs lost +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE '%Brien%') AS special_quotes FROM docs WHERE id = 'doc2' \gset +\if :special_quotes +\echo [PASS] (:testid) SpecialChars: quotes preserved +\else +\echo [FAIL] (:testid) SpecialChars: quotes lost +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: Delete vs edit — A deletes block 1, B edits block 2 +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc3', E'Alpha\nBeta\nGamma'); + +-- Sync initial to B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload3i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc3') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload3i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat \gset + +-- A: remove first line +\connect cloudsync_block_edge_a +UPDATE docs SET body = E'Beta\nGamma' WHERE id = 'doc3'; + +-- B: edit second line +\connect cloudsync_block_edge_b +UPDATE docs SET body = E'Alpha\nBetaEdited\nGamma' WHERE id = 'doc3'; + +-- Sync A -> B +\connect cloudsync_block_edge_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload3a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc3') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload3a', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc3') AS _mat \gset + +-- B should have: Alpha removed (A wins), BetaEdited kept (B's edit) +SELECT (body NOT LIKE '%Alpha%') AS dve_no_alpha FROM docs WHERE id = 'doc3' \gset +\if :dve_no_alpha +\echo [PASS] (:testid) DelVsEdit: Alpha removed +\else +\echo [FAIL] (:testid) DelVsEdit: Alpha still present +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE '%BetaEdited%') AS dve_beta FROM docs WHERE id = 'doc3' \gset +\if :dve_beta +\echo [PASS] (:testid) DelVsEdit: BetaEdited present +\else +\echo [FAIL] (:testid) DelVsEdit: BetaEdited missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE '%Gamma%') AS dve_gamma FROM docs WHERE id = 'doc3' \gset +\if :dve_gamma +\echo [PASS] (:testid) DelVsEdit: Gamma present +\else +\echo [FAIL] (:testid) DelVsEdit: Gamma missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Two block columns on the same table (body + notes) +-- ============================================================ +\connect cloudsync_block_edge_a +DROP TABLE IF EXISTS articles; +CREATE TABLE articles (id TEXT PRIMARY KEY NOT NULL, body TEXT, notes TEXT); +SELECT cloudsync_init('articles', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('articles', 'body', 'algo', 'block') AS _sc1 \gset +SELECT cloudsync_set_column('articles', 'notes', 'algo', 'block') AS _sc2 \gset + +\connect cloudsync_block_edge_b +DROP TABLE IF EXISTS articles; +CREATE TABLE articles (id TEXT PRIMARY KEY NOT NULL, body TEXT, notes TEXT); +SELECT cloudsync_init('articles', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('articles', 'body', 'algo', 'block') AS _sc1 \gset +SELECT cloudsync_set_column('articles', 'notes', 'algo', 'block') AS _sc2 \gset + +-- Insert on A +\connect cloudsync_block_edge_a +INSERT INTO articles (id, body, notes) VALUES ('art1', E'Body line 1\nBody line 2', E'Note 1\nNote 2'); + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload4 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'articles' \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload4', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('articles', 'body', 'art1') AS _mb \gset +SELECT cloudsync_text_materialize('articles', 'notes', 'art1') AS _mn \gset + +SELECT (body = E'Body line 1\nBody line 2') AS twocol_body FROM articles WHERE id = 'art1' \gset +\if :twocol_body +\echo [PASS] (:testid) TwoBlockCols: body matches +\else +\echo [FAIL] (:testid) TwoBlockCols: body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (notes = E'Note 1\nNote 2') AS twocol_notes FROM articles WHERE id = 'art1' \gset +\if :twocol_notes +\echo [PASS] (:testid) TwoBlockCols: notes matches +\else +\echo [FAIL] (:testid) TwoBlockCols: notes mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit body on A, notes on B — then sync +\connect cloudsync_block_edge_a +UPDATE articles SET body = E'Body EDITED\nBody line 2' WHERE id = 'art1'; + +\connect cloudsync_block_edge_b +UPDATE articles SET notes = E'Note 1\nNote EDITED' WHERE id = 'art1'; + +-- Sync A -> B +\connect cloudsync_block_edge_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload4b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'articles' \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload4b', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('articles', 'body', 'art1') AS _mb \gset +SELECT cloudsync_text_materialize('articles', 'notes', 'art1') AS _mn \gset + +SELECT (body LIKE '%Body EDITED%') AS twocol_body_ed FROM articles WHERE id = 'art1' \gset +\if :twocol_body_ed +\echo [PASS] (:testid) TwoBlockCols: body edited +\else +\echo [FAIL] (:testid) TwoBlockCols: body edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (notes LIKE '%Note EDITED%') AS twocol_notes_ed FROM articles WHERE id = 'art1' \gset +\if :twocol_notes_ed +\echo [PASS] (:testid) TwoBlockCols: notes kept +\else +\echo [FAIL] (:testid) TwoBlockCols: notes edit lost +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: Text -> NULL (update to NULL removes all blocks) +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc5', E'Line1\nLine2\nLine3'); + +-- Verify blocks created +SELECT (count(*) = 3) AS blk_ok FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc5') \gset +\if :blk_ok +\echo [PASS] (:testid) TextToNull: 3 blocks created +\else +\echo [FAIL] (:testid) TextToNull: wrong initial block count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update to NULL +UPDATE docs SET body = NULL WHERE id = 'doc5'; + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload5 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc5') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload5', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc5') AS _mat \gset + +SELECT (body IS NULL) AS null_remote FROM docs WHERE id = 'doc5' \gset +\if :null_remote +\echo [PASS] (:testid) TextToNull: body is NULL on remote +\else +\echo [FAIL] (:testid) TextToNull: body not NULL on remote +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 6: Payload-based sync with non-conflicting edits +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc6', E'First\nSecond\nThird'); + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload6i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc6') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload6i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc6') AS _mat \gset + +-- A edits line 1 +\connect cloudsync_block_edge_a +UPDATE docs SET body = E'FirstEdited\nSecond\nThird' WHERE id = 'doc6'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload6a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc6') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload6a', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc6') AS _mat \gset + +SELECT (body = E'FirstEdited\nSecond\nThird') AS payload_ok FROM docs WHERE id = 'doc6' \gset +\if :payload_ok +\echo [PASS] (:testid) PayloadSync: body matches +\else +\echo [FAIL] (:testid) PayloadSync: body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 7: Idempotent apply — same payload twice is a no-op +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc7', E'AAA\nBBB\nCCC'); + +-- Sync initial +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload7i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc7') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload7i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc7') AS _mat \gset + +-- A edits +\connect cloudsync_block_edge_a +UPDATE docs SET body = E'AAA-edited\nBBB\nCCC' WHERE id = 'doc7'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload7e +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc7') \gset + +-- Apply TWICE to B +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload7e', 'hex')) AS _app1 \gset +SELECT cloudsync_payload_apply(decode(:'payload7e', 'hex')) AS _app2 \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc7') AS _mat \gset + +SELECT (body LIKE '%AAA-edited%') AS idemp_ok FROM docs WHERE id = 'doc7' \gset +\if :idemp_ok +\echo [PASS] (:testid) Idempotent: body matches after double apply +\else +\echo [FAIL] (:testid) Idempotent: body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 8: Block position ordering — sequential inserts preserve order after sync +-- ============================================================ +\connect cloudsync_block_edge_a +INSERT INTO docs (id, body) VALUES ('doc8', E'Top\nBottom'); + +-- Sync initial to B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload8i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc8') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload8i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc8') AS _mat \gset + +-- A: add two lines between Top and Bottom +\connect cloudsync_block_edge_a +UPDATE docs SET body = E'Top\nMiddle1\nMiddle2\nBottom' WHERE id = 'doc8'; + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload8a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' +AND pk = cloudsync_pk_encode('doc8') \gset + +\connect cloudsync_block_edge_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload8a', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'doc8') AS _mat \gset + +SELECT (body LIKE 'Top%') AS ord_top FROM docs WHERE id = 'doc8' \gset +\if :ord_top +\echo [PASS] (:testid) Ordering: Top first +\else +\echo [FAIL] (:testid) Ordering: Top not first +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE '%Bottom') AS ord_bottom FROM docs WHERE id = 'doc8' \gset +\if :ord_bottom +\echo [PASS] (:testid) Ordering: Bottom last +\else +\echo [FAIL] (:testid) Ordering: Bottom not last +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Middle1 should come before Middle2 +SELECT (position('Middle1' IN body) < position('Middle2' IN body)) AS ord_correct FROM docs WHERE id = 'doc8' \gset +\if :ord_correct +\echo [PASS] (:testid) Ordering: Middle1 before Middle2 +\else +\echo [FAIL] (:testid) Ordering: wrong order +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body = E'Top\nMiddle1\nMiddle2\nBottom') AS ord_exact FROM docs WHERE id = 'doc8' \gset +\if :ord_exact +\echo [PASS] (:testid) Ordering: exact match +\else +\echo [FAIL] (:testid) Ordering: content mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Cleanup +-- ============================================================ +\ir helper_test_cleanup.sql +\if :should_cleanup +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_block_edge_a; +DROP DATABASE IF EXISTS cloudsync_block_edge_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/36_block_lww_round3.sql b/test/postgresql/36_block_lww_round3.sql new file mode 100644 index 0000000..7156faf --- /dev/null +++ b/test/postgresql/36_block_lww_round3.sql @@ -0,0 +1,476 @@ +-- 'Block-level LWW round 3: composite PK, empty vs null, delete+reinsert, integer PK, multi-row, non-overlapping add, long line, whitespace' + +\set testid '36' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_r3_a; +DROP DATABASE IF EXISTS cloudsync_block_r3_b; +CREATE DATABASE cloudsync_block_r3_a; +CREATE DATABASE cloudsync_block_r3_b; + +-- ============================================================ +-- Test 1: Composite primary key (text + int) with block column +-- ============================================================ +\connect cloudsync_block_r3_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (owner TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT, PRIMARY KEY(owner, seq)); +SELECT cloudsync_init('docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS docs; +CREATE TABLE docs (owner TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT, PRIMARY KEY(owner, seq)); +SELECT cloudsync_init('docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('docs', 'body', 'algo', 'block') AS _sc \gset + +-- Insert on A +\connect cloudsync_block_r3_a +INSERT INTO docs (owner, seq, body) VALUES ('alice', 1, E'Line1\nLine2\nLine3'); + +SELECT count(*) AS cpk_blocks FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('alice', 1) \gset +SELECT (:'cpk_blocks'::int = 3) AS cpk_blk_ok \gset +\if :cpk_blk_ok +\echo [PASS] (:testid) CompositePK: 3 blocks created +\else +\echo [FAIL] (:testid) CompositePK: expected 3 blocks, got :cpk_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload1 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload1', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'alice', 1) AS _mat \gset + +SELECT (body = E'Line1\nLine2\nLine3') AS cpk_body_ok FROM docs WHERE owner = 'alice' AND seq = 1 \gset +\if :cpk_body_ok +\echo [PASS] (:testid) CompositePK: body matches on B +\else +\echo [FAIL] (:testid) CompositePK: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit on B, sync back +UPDATE docs SET body = E'Line1\nEdited2\nLine3' WHERE owner = 'alice' AND seq = 1; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload1b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'docs' \gset + +\connect cloudsync_block_r3_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload1b', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('docs', 'body', 'alice', 1) AS _mat \gset + +SELECT (body = E'Line1\nEdited2\nLine3') AS cpk_rev_ok FROM docs WHERE owner = 'alice' AND seq = 1 \gset +\if :cpk_rev_ok +\echo [PASS] (:testid) CompositePK: reverse sync body matches +\else +\echo [FAIL] (:testid) CompositePK: reverse sync body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Empty string vs NULL +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS edocs; +CREATE TABLE edocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('edocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('edocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS edocs; +CREATE TABLE edocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('edocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('edocs', 'body', 'algo', 'block') AS _sc \gset + +-- Insert empty string on A +\connect cloudsync_block_r3_a +INSERT INTO edocs (id, body) VALUES ('doc1', ''); + +SELECT count(*) AS evn_blocks FROM edocs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'evn_blocks'::int = 1) AS evn_blk_ok \gset +\if :evn_blk_ok +\echo [PASS] (:testid) EmptyVsNull: 1 block for empty string +\else +\echo [FAIL] (:testid) EmptyVsNull: expected 1 block, got :evn_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync to B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload2 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'edocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload2', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('edocs', 'body', 'doc1') AS _mat \gset + +SELECT (body IS NOT NULL AND body = '') AS evn_empty_ok FROM edocs WHERE id = 'doc1' \gset +\if :evn_empty_ok +\echo [PASS] (:testid) EmptyVsNull: body is empty string (not NULL) +\else +\echo [FAIL] (:testid) EmptyVsNull: body should be empty string +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: DELETE row then re-insert with different content +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS rdocs; +CREATE TABLE rdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('rdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('rdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS rdocs; +CREATE TABLE rdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('rdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('rdocs', 'body', 'algo', 'block') AS _sc \gset + +-- Insert and sync +\connect cloudsync_block_r3_a +INSERT INTO rdocs (id, body) VALUES ('doc1', E'Old1\nOld2\nOld3'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload3i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'rdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload3i', 'hex')) AS _app \gset + +-- Delete on A +\connect cloudsync_block_r3_a +DELETE FROM rdocs WHERE id = 'doc1'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload3d +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'rdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload3d', 'hex')) AS _app \gset + +SELECT (count(*) = 0) AS dr_deleted FROM rdocs WHERE id = 'doc1' \gset +\if :dr_deleted +\echo [PASS] (:testid) DelReinsert: row deleted on B +\else +\echo [FAIL] (:testid) DelReinsert: row not deleted on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Re-insert with different content on A +\connect cloudsync_block_r3_a +INSERT INTO rdocs (id, body) VALUES ('doc1', E'New1\nNew2'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload3r +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'rdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload3r', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('rdocs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'New1\nNew2') AS dr_body_ok FROM rdocs WHERE id = 'doc1' \gset +\if :dr_body_ok +\echo [PASS] (:testid) DelReinsert: body matches after re-insert +\else +\echo [FAIL] (:testid) DelReinsert: body mismatch after re-insert +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: INTEGER primary key with block column +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS notes; +CREATE TABLE notes (id INTEGER PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('notes', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('notes', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS notes; +CREATE TABLE notes (id INTEGER PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('notes', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('notes', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_a +INSERT INTO notes (id, body) VALUES (42, E'First\nSecond\nThird'); + +SELECT count(*) AS ipk_blocks FROM notes_cloudsync_blocks WHERE pk = cloudsync_pk_encode(42) \gset +SELECT (:'ipk_blocks'::int = 3) AS ipk_blk_ok \gset +\if :ipk_blk_ok +\echo [PASS] (:testid) IntegerPK: 3 blocks created +\else +\echo [FAIL] (:testid) IntegerPK: expected 3 blocks, got :ipk_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload4 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'notes' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload4', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('notes', 'body', 42) AS _mat \gset + +SELECT (body = E'First\nSecond\nThird') AS ipk_body_ok FROM notes WHERE id = 42 \gset +\if :ipk_body_ok +\echo [PASS] (:testid) IntegerPK: body matches on B +\else +\echo [FAIL] (:testid) IntegerPK: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: Multiple rows with block columns in a single sync +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS mdocs; +CREATE TABLE mdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('mdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('mdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS mdocs; +CREATE TABLE mdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('mdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('mdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_a +INSERT INTO mdocs (id, body) VALUES ('r1', E'R1-Line1\nR1-Line2'); +INSERT INTO mdocs (id, body) VALUES ('r2', E'R2-Alpha\nR2-Beta\nR2-Gamma'); +INSERT INTO mdocs (id, body) VALUES ('r3', 'R3-Only'); +UPDATE mdocs SET body = E'R1-Edited\nR1-Line2' WHERE id = 'r1'; +UPDATE mdocs SET body = 'R3-Changed' WHERE id = 'r3'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload5 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'mdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload5', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('mdocs', 'body', 'r1') AS _m1 \gset +SELECT cloudsync_text_materialize('mdocs', 'body', 'r2') AS _m2 \gset +SELECT cloudsync_text_materialize('mdocs', 'body', 'r3') AS _m3 \gset + +SELECT (body = E'R1-Edited\nR1-Line2') AS mr_r1 FROM mdocs WHERE id = 'r1' \gset +\if :mr_r1 +\echo [PASS] (:testid) MultiRow: r1 matches +\else +\echo [FAIL] (:testid) MultiRow: r1 mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body = E'R2-Alpha\nR2-Beta\nR2-Gamma') AS mr_r2 FROM mdocs WHERE id = 'r2' \gset +\if :mr_r2 +\echo [PASS] (:testid) MultiRow: r2 matches +\else +\echo [FAIL] (:testid) MultiRow: r2 mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body = 'R3-Changed') AS mr_r3 FROM mdocs WHERE id = 'r3' \gset +\if :mr_r3 +\echo [PASS] (:testid) MultiRow: r3 matches +\else +\echo [FAIL] (:testid) MultiRow: r3 mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 6: Concurrent add at non-overlapping positions (top vs bottom) +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS ndocs; +CREATE TABLE ndocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('ndocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('ndocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS ndocs; +CREATE TABLE ndocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('ndocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('ndocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_a +INSERT INTO ndocs (id, body) VALUES ('doc1', E'A\nB\nC'); + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload6i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'ndocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload6i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('ndocs', 'body', 'doc1') AS _mat \gset + +-- A: add at top -> X A B C +\connect cloudsync_block_r3_a +UPDATE ndocs SET body = E'X\nA\nB\nC' WHERE id = 'doc1'; + +-- B: add at bottom -> A B C Y +\connect cloudsync_block_r3_b +UPDATE ndocs SET body = E'A\nB\nC\nY' WHERE id = 'doc1'; + +-- Sync A -> B +\connect cloudsync_block_r3_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload6a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'ndocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload6a', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('ndocs', 'body', 'doc1') AS _mat \gset + +SELECT (body LIKE '%X%') AS no_x FROM ndocs WHERE id = 'doc1' \gset +\if :no_x +\echo [PASS] (:testid) NonOverlap: X present +\else +\echo [FAIL] (:testid) NonOverlap: X missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE '%Y%') AS no_y FROM ndocs WHERE id = 'doc1' \gset +\if :no_y +\echo [PASS] (:testid) NonOverlap: Y present +\else +\echo [FAIL] (:testid) NonOverlap: Y missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (body LIKE 'X%' OR body LIKE E'%\nX\n%') AS no_x_before FROM ndocs WHERE id = 'doc1' \gset +\if :no_x_before +\echo [PASS] (:testid) NonOverlap: X before A +\else +\echo [FAIL] (:testid) NonOverlap: X not before A +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 7: Very long single line (10K chars) +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS ldocs; +CREATE TABLE ldocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('ldocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('ldocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS ldocs; +CREATE TABLE ldocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('ldocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('ldocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_a +INSERT INTO ldocs (id, body) VALUES ('doc1', repeat('ABCDEFGHIJ', 1000)); + +SELECT count(*) AS ll_blocks FROM ldocs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'ll_blocks'::int = 1) AS ll_blk_ok \gset +\if :ll_blk_ok +\echo [PASS] (:testid) LongLine: 1 block for 10K char line +\else +\echo [FAIL] (:testid) LongLine: expected 1 block, got :ll_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload7 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'ldocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload7', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('ldocs', 'body', 'doc1') AS _mat \gset + +SELECT (body = repeat('ABCDEFGHIJ', 1000)) AS ll_body_ok FROM ldocs WHERE id = 'doc1' \gset +\if :ll_body_ok +\echo [PASS] (:testid) LongLine: body matches on B +\else +\echo [FAIL] (:testid) LongLine: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 8: Whitespace and empty lines (delimiter edge cases) +-- ============================================================ +\connect cloudsync_block_r3_a +DROP TABLE IF EXISTS wdocs; +CREATE TABLE wdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('wdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('wdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_b +DROP TABLE IF EXISTS wdocs; +CREATE TABLE wdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('wdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('wdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r3_a +-- Text: "Line1\n\n spaces \n\t\ttabs\n\nLine6\n" = 7 blocks +INSERT INTO wdocs (id, body) VALUES ('doc1', E'Line1\n\n spaces \n\t\ttabs\n\nLine6\n'); + +SELECT count(*) AS ws_blocks FROM wdocs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'ws_blocks'::int = 7) AS ws_blk_ok \gset +\if :ws_blk_ok +\echo [PASS] (:testid) Whitespace: 7 blocks with empty/whitespace lines +\else +\echo [FAIL] (:testid) Whitespace: expected 7 blocks, got :ws_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload8 +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'wdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload8', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('wdocs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Line1\n\n spaces \n\t\ttabs\n\nLine6\n') AS ws_body_ok FROM wdocs WHERE id = 'doc1' \gset +\if :ws_body_ok +\echo [PASS] (:testid) Whitespace: body matches with whitespace preserved +\else +\echo [FAIL] (:testid) Whitespace: body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit: remove empty lines +\connect cloudsync_block_r3_a +UPDATE wdocs SET body = E'Line1\n spaces \n\t\ttabs\nLine6' WHERE id = 'doc1'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload8b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'wdocs' \gset + +\connect cloudsync_block_r3_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload8b', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('wdocs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Line1\n spaces \n\t\ttabs\nLine6') AS ws_edit_ok FROM wdocs WHERE id = 'doc1' \gset +\if :ws_edit_ok +\echo [PASS] (:testid) Whitespace: edited body matches +\else +\echo [FAIL] (:testid) Whitespace: edited body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Cleanup +-- ============================================================ +\ir helper_test_cleanup.sql +\if :should_cleanup +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_block_r3_a; +DROP DATABASE IF EXISTS cloudsync_block_r3_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/37_block_lww_round4.sql b/test/postgresql/37_block_lww_round4.sql new file mode 100644 index 0000000..2b0c77b --- /dev/null +++ b/test/postgresql/37_block_lww_round4.sql @@ -0,0 +1,500 @@ +-- 'Block-level LWW round 4: UUID PK, RLS+blocks, multi-table, 3-site convergence, custom delimiter sync, mixed column updates' + +\set testid '37' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_r4_a; +DROP DATABASE IF EXISTS cloudsync_block_r4_b; +DROP DATABASE IF EXISTS cloudsync_block_r4_c; +DROP DATABASE IF EXISTS cloudsync_block_3s_a; +DROP DATABASE IF EXISTS cloudsync_block_3s_b; +DROP DATABASE IF EXISTS cloudsync_block_3s_c; +CREATE DATABASE cloudsync_block_r4_a; +CREATE DATABASE cloudsync_block_r4_b; +CREATE DATABASE cloudsync_block_r4_c; + +-- ============================================================ +-- Test 1: UUID primary key with block column +-- ============================================================ +\connect cloudsync_block_r4_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS uuid_docs; +CREATE TABLE uuid_docs (id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), body TEXT); +SELECT cloudsync_init('uuid_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('uuid_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS uuid_docs; +CREATE TABLE uuid_docs (id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), body TEXT); +SELECT cloudsync_init('uuid_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('uuid_docs', 'body', 'algo', 'block') AS _sc \gset + +-- Insert on A with explicit UUID +\connect cloudsync_block_r4_a +INSERT INTO uuid_docs (id, body) VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', E'UUID-Line1\nUUID-Line2\nUUID-Line3'); + +SELECT count(*) AS uuid_blocks FROM uuid_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') \gset +SELECT (:'uuid_blocks'::int = 3) AS uuid_blk_ok \gset +\if :uuid_blk_ok +\echo [PASS] (:testid) UUID_PK: 3 blocks created +\else +\echo [FAIL] (:testid) UUID_PK: expected 3 blocks, got :uuid_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_uuid +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'uuid_docs' \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_uuid', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('uuid_docs', 'body', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') AS _mat \gset + +SELECT (body = E'UUID-Line1\nUUID-Line2\nUUID-Line3') AS uuid_body_ok FROM uuid_docs WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' \gset +\if :uuid_body_ok +\echo [PASS] (:testid) UUID_PK: body matches on B +\else +\echo [FAIL] (:testid) UUID_PK: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit on B, reverse sync +\connect cloudsync_block_r4_b +UPDATE uuid_docs SET body = E'UUID-Line1\nUUID-Edited\nUUID-Line3' WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_uuid_r +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'uuid_docs' \gset + +\connect cloudsync_block_r4_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_uuid_r', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('uuid_docs', 'body', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11') AS _mat \gset + +SELECT (body = E'UUID-Line1\nUUID-Edited\nUUID-Line3') AS uuid_rev_ok FROM uuid_docs WHERE id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' \gset +\if :uuid_rev_ok +\echo [PASS] (:testid) UUID_PK: reverse sync matches +\else +\echo [FAIL] (:testid) UUID_PK: reverse sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: RLS filter + block columns +-- Only rows matching filter should have block tracking +-- ============================================================ +\connect cloudsync_block_r4_a +DROP TABLE IF EXISTS rls_docs; +CREATE TABLE rls_docs (id TEXT PRIMARY KEY NOT NULL, owner_id INTEGER, body TEXT); +SELECT cloudsync_init('rls_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('rls_docs', 'body', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_filter('rls_docs', 'owner_id = 1') AS _sf \gset + +\connect cloudsync_block_r4_b +DROP TABLE IF EXISTS rls_docs; +CREATE TABLE rls_docs (id TEXT PRIMARY KEY NOT NULL, owner_id INTEGER, body TEXT); +SELECT cloudsync_init('rls_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('rls_docs', 'body', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_filter('rls_docs', 'owner_id = 1') AS _sf \gset + +-- Insert matching row (owner_id=1) and non-matching row (owner_id=2) +\connect cloudsync_block_r4_a +INSERT INTO rls_docs (id, owner_id, body) VALUES ('match1', 1, E'Filtered-Line1\nFiltered-Line2'); +INSERT INTO rls_docs (id, owner_id, body) VALUES ('nomatch', 2, E'Hidden-Line1\nHidden-Line2'); + +-- Check: matching row has blocks, non-matching does not +SELECT count(*) AS rls_match_blocks FROM rls_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('match1') \gset +SELECT count(*) AS rls_nomatch_blocks FROM rls_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('nomatch') \gset + +SELECT (:'rls_match_blocks'::int = 2) AS rls_match_ok \gset +\if :rls_match_ok +\echo [PASS] (:testid) RLS+Blocks: matching row has 2 blocks +\else +\echo [FAIL] (:testid) RLS+Blocks: expected 2 blocks for matching row, got :rls_match_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (:'rls_nomatch_blocks'::int = 0) AS rls_nomatch_ok \gset +\if :rls_nomatch_ok +\echo [PASS] (:testid) RLS+Blocks: non-matching row has 0 blocks +\else +\echo [FAIL] (:testid) RLS+Blocks: expected 0 blocks for non-matching row, got :rls_nomatch_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync: only matching row should appear in changes +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_rls +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'rls_docs' \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_rls', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('rls_docs', 'body', 'match1') AS _mat \gset + +SELECT (body = E'Filtered-Line1\nFiltered-Line2') AS rls_sync_ok FROM rls_docs WHERE id = 'match1' \gset +\if :rls_sync_ok +\echo [PASS] (:testid) RLS+Blocks: matching row synced with correct body +\else +\echo [FAIL] (:testid) RLS+Blocks: matching row body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- non-matching row should NOT exist on B +SELECT (count(*) = 0) AS rls_norow_ok FROM rls_docs WHERE id = 'nomatch' \gset +\if :rls_norow_ok +\echo [PASS] (:testid) RLS+Blocks: non-matching row not synced +\else +\echo [FAIL] (:testid) RLS+Blocks: non-matching row should not exist on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: Multi-table blocks — two tables with block columns in same payload +-- ============================================================ +\connect cloudsync_block_r4_a +DROP TABLE IF EXISTS articles; +DROP TABLE IF EXISTS comments; +CREATE TABLE articles (id TEXT PRIMARY KEY NOT NULL, content TEXT); +CREATE TABLE comments (id TEXT PRIMARY KEY NOT NULL, text_body TEXT); +SELECT cloudsync_init('articles', 'CLS', true) AS _init \gset +SELECT cloudsync_init('comments', 'CLS', true) AS _init2 \gset +SELECT cloudsync_set_column('articles', 'content', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_column('comments', 'text_body', 'algo', 'block') AS _sc2 \gset + +\connect cloudsync_block_r4_b +DROP TABLE IF EXISTS articles; +DROP TABLE IF EXISTS comments; +CREATE TABLE articles (id TEXT PRIMARY KEY NOT NULL, content TEXT); +CREATE TABLE comments (id TEXT PRIMARY KEY NOT NULL, text_body TEXT); +SELECT cloudsync_init('articles', 'CLS', true) AS _init \gset +SELECT cloudsync_init('comments', 'CLS', true) AS _init2 \gset +SELECT cloudsync_set_column('articles', 'content', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_column('comments', 'text_body', 'algo', 'block') AS _sc2 \gset + +\connect cloudsync_block_r4_a +INSERT INTO articles (id, content) VALUES ('art1', E'Para1\nPara2\nPara3'); +INSERT INTO comments (id, text_body) VALUES ('cmt1', E'Comment-Line1\nComment-Line2'); +UPDATE articles SET content = E'Para1-Edited\nPara2\nPara3' WHERE id = 'art1'; + +-- Single payload containing changes from both tables +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_mt +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_mt', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('articles', 'content', 'art1') AS _m1 \gset +SELECT cloudsync_text_materialize('comments', 'text_body', 'cmt1') AS _m2 \gset + +SELECT (content = E'Para1-Edited\nPara2\nPara3') AS mt_art_ok FROM articles WHERE id = 'art1' \gset +\if :mt_art_ok +\echo [PASS] (:testid) MultiTable: articles content matches +\else +\echo [FAIL] (:testid) MultiTable: articles content mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (text_body = E'Comment-Line1\nComment-Line2') AS mt_cmt_ok FROM comments WHERE id = 'cmt1' \gset +\if :mt_cmt_ok +\echo [PASS] (:testid) MultiTable: comments text_body matches +\else +\echo [FAIL] (:testid) MultiTable: comments text_body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Three-site convergence with block columns +-- All three sites make different edits, pairwise sync, verify convergence +-- Uses dedicated databases so all 3 have identical schema +-- ============================================================ +\connect postgres +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_block_3s_a; +DROP DATABASE IF EXISTS cloudsync_block_3s_b; +DROP DATABASE IF EXISTS cloudsync_block_3s_c; +CREATE DATABASE cloudsync_block_3s_a; +CREATE DATABASE cloudsync_block_3s_b; +CREATE DATABASE cloudsync_block_3s_c; + +\connect cloudsync_block_3s_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +CREATE TABLE tdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('tdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('tdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_3s_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +CREATE TABLE tdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('tdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('tdocs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_3s_c +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +CREATE TABLE tdocs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('tdocs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('tdocs', 'body', 'algo', 'block') AS _sc \gset + +-- Initial insert on A, sync to B and C +\connect cloudsync_block_3s_a +INSERT INTO tdocs (id, body) VALUES ('doc1', E'Line1\nLine2\nLine3\nLine4\nLine5'); + +-- Full changes from A (includes schema info) +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3s_init +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'tdocs' \gset + +\connect cloudsync_block_3s_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3s_init', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('tdocs', 'body', 'doc1') AS _mat \gset + +\connect cloudsync_block_3s_c +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3s_init', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('tdocs', 'body', 'doc1') AS _mat \gset + +-- Each site edits a DIFFERENT line (no conflicts) +-- A edits line 1 +\connect cloudsync_block_3s_a +UPDATE tdocs SET body = E'Line1-A\nLine2\nLine3\nLine4\nLine5' WHERE id = 'doc1'; + +-- B edits line 3 +\connect cloudsync_block_3s_b +UPDATE tdocs SET body = E'Line1\nLine2\nLine3-B\nLine4\nLine5' WHERE id = 'doc1'; + +-- C edits line 5 +\connect cloudsync_block_3s_c +UPDATE tdocs SET body = E'Line1\nLine2\nLine3\nLine4\nLine5-C' WHERE id = 'doc1'; + +-- Collect ALL changes from each site (not filtered by site_id) +-- This includes the schema info that recipients need +\connect cloudsync_block_3s_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3s_a +FROM cloudsync_changes WHERE tbl = 'tdocs' \gset + +\connect cloudsync_block_3s_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3s_b +FROM cloudsync_changes WHERE tbl = 'tdocs' \gset + +\connect cloudsync_block_3s_c +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_3s_c +FROM cloudsync_changes WHERE tbl = 'tdocs' \gset + +-- Apply all to A (B's and C's changes) +\connect cloudsync_block_3s_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3s_b', 'hex')) AS _app_b \gset +SELECT cloudsync_payload_apply(decode(:'payload_3s_c', 'hex')) AS _app_c \gset +SELECT cloudsync_text_materialize('tdocs', 'body', 'doc1') AS _mat \gset + +-- Apply all to B (A's and C's changes) +\connect cloudsync_block_3s_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3s_a', 'hex')) AS _app_a \gset +SELECT cloudsync_payload_apply(decode(:'payload_3s_c', 'hex')) AS _app_c \gset +SELECT cloudsync_text_materialize('tdocs', 'body', 'doc1') AS _mat \gset + +-- Apply all to C (A's and B's changes) +\connect cloudsync_block_3s_c +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_3s_a', 'hex')) AS _app_a \gset +SELECT cloudsync_payload_apply(decode(:'payload_3s_b', 'hex')) AS _app_b \gset +SELECT cloudsync_text_materialize('tdocs', 'body', 'doc1') AS _mat \gset + +-- All three should converge +\connect cloudsync_block_3s_a +SELECT body AS body_a FROM tdocs WHERE id = 'doc1' \gset +\connect cloudsync_block_3s_b +SELECT body AS body_b FROM tdocs WHERE id = 'doc1' \gset +\connect cloudsync_block_3s_c +SELECT body AS body_c FROM tdocs WHERE id = 'doc1' \gset + +SELECT (:'body_a' = :'body_b') AS ab_match \gset +SELECT (:'body_b' = :'body_c') AS bc_match \gset + +\if :ab_match +\echo [PASS] (:testid) 3-Site: A and B converge +\else +\echo [FAIL] (:testid) 3-Site: A and B diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :bc_match +\echo [PASS] (:testid) 3-Site: B and C converge +\else +\echo [FAIL] (:testid) 3-Site: B and C diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- All edits should be present (non-conflicting) +SELECT (position('Line1-A' in :'body_a') > 0) AS has_a \gset +SELECT (position('Line3-B' in :'body_a') > 0) AS has_b \gset +SELECT (position('Line5-C' in :'body_a') > 0) AS has_c \gset + +\if :has_a +\echo [PASS] (:testid) 3-Site: Site A edit present +\else +\echo [FAIL] (:testid) 3-Site: Site A edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :has_b +\echo [PASS] (:testid) 3-Site: Site B edit present +\else +\echo [FAIL] (:testid) 3-Site: Site B edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :has_c +\echo [PASS] (:testid) 3-Site: Site C edit present +\else +\echo [FAIL] (:testid) 3-Site: Site C edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: Custom delimiter sync roundtrip +-- Uses paragraph delimiter (double newline), edits, syncs +-- ============================================================ +\connect cloudsync_block_r4_a +DROP TABLE IF EXISTS para_docs; +CREATE TABLE para_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('para_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('para_docs', 'body', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_column('para_docs', 'body', 'delimiter', E'\n\n') AS _sd \gset + +\connect cloudsync_block_r4_b +DROP TABLE IF EXISTS para_docs; +CREATE TABLE para_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('para_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('para_docs', 'body', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_column('para_docs', 'body', 'delimiter', E'\n\n') AS _sd \gset + +\connect cloudsync_block_r4_a +INSERT INTO para_docs (id, body) VALUES ('doc1', E'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.'); + +SELECT count(*) AS pd_blocks FROM para_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'pd_blocks'::int = 3) AS pd_blk_ok \gset +\if :pd_blk_ok +\echo [PASS] (:testid) CustomDelimSync: 3 paragraph blocks +\else +\echo [FAIL] (:testid) CustomDelimSync: expected 3 blocks, got :pd_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_pd +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'para_docs' \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_pd', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('para_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.') AS pd_sync_ok FROM para_docs WHERE id = 'doc1' \gset +\if :pd_sync_ok +\echo [PASS] (:testid) CustomDelimSync: body matches on B +\else +\echo [FAIL] (:testid) CustomDelimSync: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit paragraph 2 on B, sync back +\connect cloudsync_block_r4_b +UPDATE para_docs SET body = E'First paragraph.\n\nEdited second paragraph.\n\nThird paragraph.' WHERE id = 'doc1'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_pd_r +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'para_docs' \gset + +\connect cloudsync_block_r4_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_pd_r', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('para_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'First paragraph.\n\nEdited second paragraph.\n\nThird paragraph.') AS pd_rev_ok FROM para_docs WHERE id = 'doc1' \gset +\if :pd_rev_ok +\echo [PASS] (:testid) CustomDelimSync: reverse sync matches +\else +\echo [FAIL] (:testid) CustomDelimSync: reverse sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 6: Block column + regular LWW column — mixed update +-- Single UPDATE changes both block col and regular col +-- ============================================================ +\connect cloudsync_block_r4_a +DROP TABLE IF EXISTS mixed_docs; +CREATE TABLE mixed_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT, title TEXT); +SELECT cloudsync_init('mixed_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('mixed_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r4_b +DROP TABLE IF EXISTS mixed_docs; +CREATE TABLE mixed_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT, title TEXT); +SELECT cloudsync_init('mixed_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('mixed_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r4_a +INSERT INTO mixed_docs (id, body, title) VALUES ('doc1', E'Body-Line1\nBody-Line2', 'Original Title'); + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_mix_i +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'mixed_docs' \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_mix_i', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('mixed_docs', 'body', 'doc1') AS _mat \gset + +-- Update BOTH columns simultaneously on A +\connect cloudsync_block_r4_a +UPDATE mixed_docs SET body = E'Body-Edited1\nBody-Line2', title = 'New Title' WHERE id = 'doc1'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_mix_u +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'mixed_docs' \gset + +\connect cloudsync_block_r4_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_mix_u', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('mixed_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Body-Edited1\nBody-Line2') AS mix_body_ok FROM mixed_docs WHERE id = 'doc1' \gset +\if :mix_body_ok +\echo [PASS] (:testid) MixedUpdate: block column body matches +\else +\echo [FAIL] (:testid) MixedUpdate: block column body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT (title = 'New Title') AS mix_title_ok FROM mixed_docs WHERE id = 'doc1' \gset +\if :mix_title_ok +\echo [PASS] (:testid) MixedUpdate: regular column title matches +\else +\echo [FAIL] (:testid) MixedUpdate: regular column title mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Cleanup +-- ============================================================ +\ir helper_test_cleanup.sql +\if :should_cleanup +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_block_r4_a; +DROP DATABASE IF EXISTS cloudsync_block_r4_b; +DROP DATABASE IF EXISTS cloudsync_block_r4_c; +DROP DATABASE IF EXISTS cloudsync_block_3s_a; +DROP DATABASE IF EXISTS cloudsync_block_3s_b; +DROP DATABASE IF EXISTS cloudsync_block_3s_c; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/38_block_lww_round5.sql b/test/postgresql/38_block_lww_round5.sql new file mode 100644 index 0000000..8e796f0 --- /dev/null +++ b/test/postgresql/38_block_lww_round5.sql @@ -0,0 +1,433 @@ +-- 'Block-level LWW round 5: large blocks, payload idempotency composite PK, init with existing data, drop/re-add block config, delimiter-in-content' + +\set testid '38' +\ir helper_test_init.sql + +\connect postgres +\ir helper_psql_conn_setup.sql + +DROP DATABASE IF EXISTS cloudsync_block_r5_a; +DROP DATABASE IF EXISTS cloudsync_block_r5_b; +CREATE DATABASE cloudsync_block_r5_a; +CREATE DATABASE cloudsync_block_r5_b; + +-- ============================================================ +-- Test 7: Large number of blocks (200+ lines) +-- Verify diff and materialize work correctly at scale +-- ============================================================ +\connect cloudsync_block_r5_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS big_docs; +CREATE TABLE big_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('big_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('big_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; +DROP TABLE IF EXISTS big_docs; +CREATE TABLE big_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('big_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('big_docs', 'body', 'algo', 'block') AS _sc \gset + +-- Generate 250-line text +\connect cloudsync_block_r5_a +INSERT INTO big_docs (id, body) +SELECT 'doc1', string_agg('Line-' || gs::text, E'\n' ORDER BY gs) +FROM generate_series(1, 250) gs; + +SELECT count(*) AS big_blocks FROM big_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'big_blocks'::int = 250) AS big_blk_ok \gset +\if :big_blk_ok +\echo [PASS] (:testid) LargeBlocks: 250 blocks created +\else +\echo [FAIL] (:testid) LargeBlocks: expected 250 blocks, got :big_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit a few lines scattered through the document +UPDATE big_docs SET body = ( + SELECT string_agg( + CASE + WHEN gs = 50 THEN 'EDITED-50' + WHEN gs = 150 THEN 'EDITED-150' + WHEN gs = 200 THEN 'EDITED-200' + ELSE 'Line-' || gs::text + END, + E'\n' ORDER BY gs + ) FROM generate_series(1, 250) gs +) WHERE id = 'doc1'; + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_big +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'big_docs' \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_big', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('big_docs', 'body', 'doc1') AS _mat \gset + +-- Verify edited lines are present +SELECT (position('EDITED-50' in body) > 0) AS big_e50 FROM big_docs WHERE id = 'doc1' \gset +SELECT (position('EDITED-150' in body) > 0) AS big_e150 FROM big_docs WHERE id = 'doc1' \gset +SELECT (position('EDITED-200' in body) > 0) AS big_e200 FROM big_docs WHERE id = 'doc1' \gset + +\if :big_e50 +\echo [PASS] (:testid) LargeBlocks: EDITED-50 present +\else +\echo [FAIL] (:testid) LargeBlocks: EDITED-50 missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :big_e150 +\echo [PASS] (:testid) LargeBlocks: EDITED-150 present +\else +\echo [FAIL] (:testid) LargeBlocks: EDITED-150 missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :big_e200 +\echo [PASS] (:testid) LargeBlocks: EDITED-200 present +\else +\echo [FAIL] (:testid) LargeBlocks: EDITED-200 missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify block count still 250 (edits don't change count) +SELECT count(*) AS big_blocks2 FROM big_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'big_blocks2'::int = 250) AS big_cnt_ok \gset +\if :big_cnt_ok +\echo [PASS] (:testid) LargeBlocks: block count stable after sync +\else +\echo [FAIL] (:testid) LargeBlocks: expected 250 blocks after sync, got :big_blocks2 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 8: Payload idempotency with composite PK +-- Apply same payload twice, verify no duplication or corruption +-- ============================================================ +\connect cloudsync_block_r5_a +DROP TABLE IF EXISTS idem_docs; +CREATE TABLE idem_docs (owner TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT, PRIMARY KEY(owner, seq)); +SELECT cloudsync_init('idem_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('idem_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r5_b +DROP TABLE IF EXISTS idem_docs; +CREATE TABLE idem_docs (owner TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT, PRIMARY KEY(owner, seq)); +SELECT cloudsync_init('idem_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('idem_docs', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r5_a +INSERT INTO idem_docs (owner, seq, body) VALUES ('bob', 1, E'Idem-Line1\nIdem-Line2\nIdem-Line3'); +UPDATE idem_docs SET body = E'Idem-Line1\nIdem-Edited\nIdem-Line3' WHERE owner = 'bob' AND seq = 1; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_idem +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'idem_docs' \gset + +-- Apply on B — first time +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_idem', 'hex')) AS _app1 \gset +SELECT cloudsync_text_materialize('idem_docs', 'body', 'bob', 1) AS _mat1 \gset + +SELECT (body = E'Idem-Line1\nIdem-Edited\nIdem-Line3') AS idem1_ok FROM idem_docs WHERE owner = 'bob' AND seq = 1 \gset +\if :idem1_ok +\echo [PASS] (:testid) Idempotent: first apply correct +\else +\echo [FAIL] (:testid) Idempotent: first apply mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT count(*) AS idem_meta1 FROM idem_docs_cloudsync WHERE pk = cloudsync_pk_encode('bob', 1) \gset + +-- Apply SAME payload again — second time (idempotent) +SELECT cloudsync_payload_apply(decode(:'payload_idem', 'hex')) AS _app2 \gset +SELECT cloudsync_text_materialize('idem_docs', 'body', 'bob', 1) AS _mat2 \gset + +SELECT (body = E'Idem-Line1\nIdem-Edited\nIdem-Line3') AS idem2_ok FROM idem_docs WHERE owner = 'bob' AND seq = 1 \gset +\if :idem2_ok +\echo [PASS] (:testid) Idempotent: second apply still correct +\else +\echo [FAIL] (:testid) Idempotent: body corrupted after double apply +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Metadata count should not change +SELECT count(*) AS idem_meta2 FROM idem_docs_cloudsync WHERE pk = cloudsync_pk_encode('bob', 1) \gset +SELECT (:'idem_meta1' = :'idem_meta2') AS idem_meta_ok \gset +\if :idem_meta_ok +\echo [PASS] (:testid) Idempotent: metadata count unchanged after double apply +\else +\echo [FAIL] (:testid) Idempotent: metadata count changed (:idem_meta1 vs :idem_meta2) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 9: Init with pre-existing data, then enable block column +-- Table has rows before cloudsync_set_column algo=block +-- ============================================================ +\connect cloudsync_block_r5_a +DROP TABLE IF EXISTS predata; +CREATE TABLE predata (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('predata', 'CLS', true) AS _init \gset + +-- Insert rows BEFORE enabling block algorithm +INSERT INTO predata (id, body) VALUES ('pre1', E'Pre-Line1\nPre-Line2'); +INSERT INTO predata (id, body) VALUES ('pre2', E'Pre-Alpha\nPre-Beta\nPre-Gamma'); + +-- Now enable block on the column +SELECT cloudsync_set_column('predata', 'body', 'algo', 'block') AS _sc \gset + +\connect cloudsync_block_r5_b +DROP TABLE IF EXISTS predata; +CREATE TABLE predata (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('predata', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('predata', 'body', 'algo', 'block') AS _sc \gset + +-- Update a pre-existing row on A to trigger block creation +\connect cloudsync_block_r5_a +UPDATE predata SET body = E'Pre-Line1\nPre-Edited2' WHERE id = 'pre1'; + +SELECT count(*) AS pre_blocks FROM predata_cloudsync_blocks WHERE pk = cloudsync_pk_encode('pre1') \gset +SELECT (:'pre_blocks'::int >= 2) AS pre_blk_ok \gset +\if :pre_blk_ok +\echo [PASS] (:testid) PreExisting: blocks created after update +\else +\echo [FAIL] (:testid) PreExisting: expected >= 2 blocks, got :pre_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync to B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_pre +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'predata' \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_pre', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('predata', 'body', 'pre1') AS _mat \gset + +SELECT (body = E'Pre-Line1\nPre-Edited2') AS pre_sync_ok FROM predata WHERE id = 'pre1' \gset +\if :pre_sync_ok +\echo [PASS] (:testid) PreExisting: synced body matches after late block enable +\else +\echo [FAIL] (:testid) PreExisting: synced body mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- pre2 should also sync (as regular LWW or with insert sentinel) +SELECT (count(*) = 1) AS pre2_exists FROM predata WHERE id = 'pre2' \gset +\if :pre2_exists +\echo [PASS] (:testid) PreExisting: pre2 row synced +\else +\echo [FAIL] (:testid) PreExisting: pre2 row missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 10: Remove block algo then re-add +-- ============================================================ +\connect cloudsync_block_r5_a +DROP TABLE IF EXISTS toggle_docs; +CREATE TABLE toggle_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('toggle_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('toggle_docs', 'body', 'algo', 'block') AS _sc1 \gset + +\connect cloudsync_block_r5_b +DROP TABLE IF EXISTS toggle_docs; +CREATE TABLE toggle_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('toggle_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('toggle_docs', 'body', 'algo', 'block') AS _sc1 \gset + +-- Insert with blocks on A +\connect cloudsync_block_r5_a +INSERT INTO toggle_docs (id, body) VALUES ('doc1', E'Toggle-Line1\nToggle-Line2'); + +SELECT count(*) AS tog_blocks1 FROM toggle_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'tog_blocks1'::int = 2) AS tog_blk1_ok \gset +\if :tog_blk1_ok +\echo [PASS] (:testid) Toggle: blocks created initially +\else +\echo [FAIL] (:testid) Toggle: expected 2 blocks initially, got :tog_blocks1 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Remove block algo (set to default LWW) +SELECT cloudsync_set_column('toggle_docs', 'body', 'algo', 'lww') AS _sc2 \gset + +-- Update while in LWW mode — should NOT create new blocks +UPDATE toggle_docs SET body = E'Toggle-LWW-Updated' WHERE id = 'doc1'; + +-- Re-enable block algo +SELECT cloudsync_set_column('toggle_docs', 'body', 'algo', 'block') AS _sc3 \gset + +-- Update with blocks re-enabled +UPDATE toggle_docs SET body = E'Toggle-Block-Again1\nToggle-Block-Again2\nToggle-Block-Again3' WHERE id = 'doc1'; + +SELECT count(*) AS tog_blocks2 FROM toggle_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'tog_blocks2'::int = 3) AS tog_blk2_ok \gset +\if :tog_blk2_ok +\echo [PASS] (:testid) Toggle: 3 blocks after re-enable +\else +\echo [FAIL] (:testid) Toggle: expected 3 blocks after re-enable, got :tog_blocks2 +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync to B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_tog +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'toggle_docs' \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_tog', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('toggle_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Toggle-Block-Again1\nToggle-Block-Again2\nToggle-Block-Again3') AS tog_sync_ok FROM toggle_docs WHERE id = 'doc1' \gset +\if :tog_sync_ok +\echo [PASS] (:testid) Toggle: body matches after re-enable and sync +\else +\echo [FAIL] (:testid) Toggle: body mismatch after re-enable and sync +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 11: Text containing the delimiter character as content +-- Default delimiter is \n — content has no real structure, just embedded newlines +-- ============================================================ +\connect cloudsync_block_r5_a +DROP TABLE IF EXISTS delim_docs; +CREATE TABLE delim_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('delim_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('delim_docs', 'body', 'algo', 'block') AS _sc \gset +-- Use paragraph delimiter (double newline) +SELECT cloudsync_set_column('delim_docs', 'body', 'delimiter', E'\n\n') AS _sd \gset + +\connect cloudsync_block_r5_b +DROP TABLE IF EXISTS delim_docs; +CREATE TABLE delim_docs (id TEXT PRIMARY KEY NOT NULL, body TEXT); +SELECT cloudsync_init('delim_docs', 'CLS', true) AS _init \gset +SELECT cloudsync_set_column('delim_docs', 'body', 'algo', 'block') AS _sc \gset +SELECT cloudsync_set_column('delim_docs', 'body', 'delimiter', E'\n\n') AS _sd \gset + +-- Content with single newlines inside paragraphs (not delimiters) +\connect cloudsync_block_r5_a +INSERT INTO delim_docs (id, body) VALUES ('doc1', E'Paragraph one\nstill paragraph one.\n\nParagraph two\nstill para two.\n\nParagraph three.'); + +-- Should be 3 blocks (split by double newline) +SELECT count(*) AS dc_blocks FROM delim_docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1') \gset +SELECT (:'dc_blocks'::int = 3) AS dc_blk_ok \gset +\if :dc_blk_ok +\echo [PASS] (:testid) DelimContent: 3 paragraph blocks (single newlines inside) +\else +\echo [FAIL] (:testid) DelimContent: expected 3 blocks, got :dc_blocks +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Sync A -> B +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_dc +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'delim_docs' \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_dc', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('delim_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Paragraph one\nstill paragraph one.\n\nParagraph two\nstill para two.\n\nParagraph three.') AS dc_sync_ok FROM delim_docs WHERE id = 'doc1' \gset +\if :dc_sync_ok +\echo [PASS] (:testid) DelimContent: body matches on B (embedded newlines preserved) +\else +\echo [FAIL] (:testid) DelimContent: body mismatch on B +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Edit paragraph 2 on B (change only the second paragraph), sync back +\connect cloudsync_block_r5_b +UPDATE delim_docs SET body = E'Paragraph one\nstill paragraph one.\n\nEdited paragraph two.\n\nParagraph three.' WHERE id = 'doc1'; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_dc_r +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'delim_docs' \gset + +\connect cloudsync_block_r5_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_dc_r', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('delim_docs', 'body', 'doc1') AS _mat \gset + +SELECT (body = E'Paragraph one\nstill paragraph one.\n\nEdited paragraph two.\n\nParagraph three.') AS dc_rev_ok FROM delim_docs WHERE id = 'doc1' \gset +\if :dc_rev_ok +\echo [PASS] (:testid) DelimContent: reverse sync matches (paragraph edit) +\else +\echo [FAIL] (:testid) DelimContent: reverse sync mismatch +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Concurrent edit: A edits para 1, B edits para 3 +\connect cloudsync_block_r5_a +UPDATE delim_docs SET body = E'Edited para one by A.\n\nEdited paragraph two.\n\nParagraph three.' WHERE id = 'doc1'; + +\connect cloudsync_block_r5_b +UPDATE delim_docs SET body = E'Paragraph one\nstill paragraph one.\n\nEdited paragraph two.\n\nEdited para three by B.' WHERE id = 'doc1'; + +\connect cloudsync_block_r5_a +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_dc_a +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'delim_docs' \gset + +\connect cloudsync_block_r5_b +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_dc_b +FROM cloudsync_changes WHERE site_id = cloudsync_siteid() AND tbl = 'delim_docs' \gset + +-- Apply cross +\connect cloudsync_block_r5_a +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_dc_b', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('delim_docs', 'body', 'doc1') AS _mat \gset + +\connect cloudsync_block_r5_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload_dc_a', 'hex')) AS _app \gset +SELECT cloudsync_text_materialize('delim_docs', 'body', 'doc1') AS _mat \gset + +-- Both should converge and both edits should be present +\connect cloudsync_block_r5_a +SELECT md5(body) AS dc_md5_a FROM delim_docs WHERE id = 'doc1' \gset +\connect cloudsync_block_r5_b +SELECT md5(body) AS dc_md5_b FROM delim_docs WHERE id = 'doc1' \gset + +SELECT (:'dc_md5_a' = :'dc_md5_b') AS dc_converge \gset +\if :dc_converge +\echo [PASS] (:testid) DelimContent: concurrent paragraph edits converge +\else +\echo [FAIL] (:testid) DelimContent: concurrent paragraph edits diverged +SELECT (:fail::int + 1) AS fail \gset +\endif + +\connect cloudsync_block_r5_a +SELECT (position('Edited para one by A.' in body) > 0) AS dc_has_a FROM delim_docs WHERE id = 'doc1' \gset +SELECT (position('Edited para three by B.' in body) > 0) AS dc_has_b FROM delim_docs WHERE id = 'doc1' \gset + +\if :dc_has_a +\echo [PASS] (:testid) DelimContent: site A paragraph edit present +\else +\echo [FAIL] (:testid) DelimContent: site A paragraph edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +\if :dc_has_b +\echo [PASS] (:testid) DelimContent: site B paragraph edit present +\else +\echo [FAIL] (:testid) DelimContent: site B paragraph edit missing +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Cleanup +-- ============================================================ +\ir helper_test_cleanup.sql +\if :should_cleanup +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_block_r5_a; +DROP DATABASE IF EXISTS cloudsync_block_r5_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/full_test.sql b/test/postgresql/full_test.sql index e3337fc..d02440a 100644 --- a/test/postgresql/full_test.sql +++ b/test/postgresql/full_test.sql @@ -38,8 +38,14 @@ \ir 28_db_version_tracking.sql \ir 29_rls_multicol.sql \ir 30_null_prikey_insert.sql - \ir 31_alter_table_sync.sql +\ir 32_block_lww.sql +\ir 33_block_lww_extended.sql +\ir 34_block_lww_advanced.sql +\ir 35_block_lww_edge_cases.sql +\ir 36_block_lww_round3.sql +\ir 37_block_lww_round4.sql +\ir 38_block_lww_round5.sql -- 'Test summary' \echo '\nTest summary:' diff --git a/test/unit.c b/test/unit.c index 6454c5e..0487f9d 100644 --- a/test/unit.c +++ b/test/unit.c @@ -7850,6 +7850,2300 @@ bool do_test_rls_trigger_denial (int nclients, bool print_result, bool cleanup_d return result; } +// MARK: - Block-level LWW Tests - + +static int64_t do_select_int(sqlite3 *db, const char *sql) { + sqlite3_stmt *stmt = NULL; + int64_t val = -1; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + val = sqlite3_column_int64(stmt, 0); + } + } + if (stmt) sqlite3_finalize(stmt); + return val; +} + +static char *do_select_text(sqlite3 *db, const char *sql) { + sqlite3_stmt *stmt = NULL; + char *val = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *t = (const char *)sqlite3_column_text(stmt, 0); + if (t) val = sqlite3_mprintf("%s", t); + } + } + if (stmt) sqlite3_finalize(stmt); + return val; +} + +bool do_test_block_lww_insert(int nclients, bool print_result, bool cleanup_databases) { + // Test: INSERT into a table with a block column properly splits text into blocks + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_insert: CREATE TABLE failed: %s\n", sqlite3_errmsg(db[i])); goto fail; } + + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_insert: cloudsync_init failed: %s\n", sqlite3_errmsg(db[i])); goto fail; } + + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_insert: set_column failed: %s\n", sqlite3_errmsg(db[i])); goto fail; } + } + + // Insert a document with 3 lines + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line 1\nLine 2\nLine 3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_insert: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Verify blocks were created in the blocks table + int64_t block_count = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (block_count != 3) { + printf("block_insert: expected 3 blocks, got %" PRId64 "\n", block_count); + goto fail; + } + + // Verify metadata entries for blocks (col_name contains \x1F) + int64_t meta_count = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (meta_count != 3) { + printf("block_insert: expected 3 block metadata entries, got %" PRId64 "\n", meta_count); + goto fail; + } + + // Verify no metadata entry for the whole 'body' column + int64_t whole_meta = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name = 'body';"); + if (whole_meta != 0) { + printf("block_insert: expected 0 whole-column metadata entries, got %" PRId64 "\n", whole_meta); + goto fail; + } + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +bool do_test_block_lww_update(int nclients, bool print_result, bool cleanup_databases) { + // Test: UPDATE on a block column performs block diff + sqlite3 *db[1] = {NULL}; + time_t timestamp = time(NULL); + int rc; + + db[0] = do_create_database_file(0, timestamp, test_counter++); + if (!db[0]) return false; + + rc = sqlite3_exec(db[0], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Insert initial text + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'AAA\nBBB\nCCC');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_update: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + int64_t blocks_before = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + + // Update: change middle line and add a new line + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'AAA\nXXX\nCCC\nDDD' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_update: UPDATE failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + int64_t blocks_after = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + + // Should have 4 blocks after update (AAA, XXX, CCC, DDD) + if (blocks_after != 4) { + printf("block_update: expected 4 blocks after update, got %" PRId64 " (before: %" PRId64 ")\n", blocks_after, blocks_before); + goto fail; + } + + close_db(db[0]); + return true; + +fail: + if (db[0]) close_db(db[0]); + return false; +} + +bool do_test_block_lww_sync(int nclients, bool print_result, bool cleanup_databases) { + // Test: Two sites edit different blocks of the same document; after sync, both edits are preserved + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0 inserts the initial document + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line A\nLine B\nLine C');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_sync: INSERT db[0] failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Sync initial state: db[0] -> db[1] so both have the same document + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("block_sync: initial merge 0->1 failed\n"); goto fail; } + + // Site 0: edit first line + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'EDITED A\nLine B\nLine C' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_sync: UPDATE db[0] failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Site 1: edit third line + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Line A\nLine B\nEDITED C' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_sync: UPDATE db[1] failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + // Sync: db[0] -> db[1] (send site 0's edits) + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("block_sync: merge 0->1 failed\n"); goto fail; } + // Sync: db[1] -> db[0] (send site 1's edits) + if (!do_merge_using_payload(db[1], db[0], true, true)) { printf("block_sync: merge 1->0 failed\n"); goto fail; } + + // Both databases should now have the merged result: "EDITED A\nLine B\nEDITED C" + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1) { + printf("block_sync: could not read body from one or both databases\n"); + ok = false; + } else if (strcmp(body0, body1) != 0) { + printf("block_sync: bodies don't match after sync:\n db[0]: %s\n db[1]: %s\n", body0, body1); + ok = false; + } else { + // Check that both edits were preserved + if (!strstr(body0, "EDITED A")) { + printf("block_sync: missing 'EDITED A' in result: %s\n", body0); + ok = false; + } + if (!strstr(body0, "EDITED C")) { + printf("block_sync: missing 'EDITED C' in result: %s\n", body0); + ok = false; + } + if (!strstr(body0, "Line B")) { + printf("block_sync: missing 'Line B' in result: %s\n", body0); + ok = false; + } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +bool do_test_block_lww_delete(int nclients, bool print_result, bool cleanup_databases) { + // Test: DELETE on a row with block columns marks tombstone and block metadata is dropped + sqlite3 *db[1] = {NULL}; + time_t timestamp = time(NULL); + int rc; + + db[0] = do_create_database_file(0, timestamp, test_counter++); + if (!db[0]) return false; + + rc = sqlite3_exec(db[0], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Insert a document + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line A\nLine B\nLine C');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_delete: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Verify blocks and metadata exist + int64_t blocks_before = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + if (blocks_before != 3) { + printf("block_delete: expected 3 blocks before delete, got %" PRId64 "\n", blocks_before); + goto fail; + } + int64_t meta_before = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (meta_before != 3) { + printf("block_delete: expected 3 block metadata before delete, got %" PRId64 "\n", meta_before); + goto fail; + } + + // Delete the row + rc = sqlite3_exec(db[0], "DELETE FROM docs WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_delete: DELETE failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Verify metadata tombstone exists (delete sentinel) + int64_t tombstone = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name = '__[RIP]__' AND col_version % 2 = 0;"); + if (tombstone != 1) { + printf("block_delete: expected 1 delete tombstone, got %" PRId64 "\n", tombstone); + goto fail; + } + + // Verify block metadata was dropped (local_drop_meta removes non-tombstone metadata) + int64_t meta_after = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (meta_after != 0) { + printf("block_delete: expected 0 block metadata after delete, got %" PRId64 "\n", meta_after); + goto fail; + } + + // Row should be gone from base table + int64_t row_count = do_select_int(db[0], "SELECT count(*) FROM docs WHERE id = 'doc1';"); + if (row_count != 0) { + printf("block_delete: row still in base table after delete\n"); + goto fail; + } + + close_db(db[0]); + return true; + +fail: + if (db[0]) close_db(db[0]); + return false; +} + +bool do_test_block_lww_materialize(int nclients, bool print_result, bool cleanup_databases) { + // Test: cloudsync_text_materialize reconstructs text from blocks after sync + // Sync to a second db where body column is empty, then materialize there + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert multi-line text on db[0] + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Alpha\nBravo\nCharlie\nDelta\nEcho');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_materialize: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Sync to db[1] — body column on db[1] will be populated by payload_apply but + // materialize should reconstruct correctly from blocks + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("block_materialize: merge failed\n"); goto fail; } + + // Materialize on db[1] should reconstruct from blocks + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_materialize: materialize failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body) { + printf("block_materialize: body is NULL after materialize\n"); + goto fail; + } + if (strcmp(body, "Alpha\nBravo\nCharlie\nDelta\nEcho") != 0) { + printf("block_materialize: body mismatch: %s\n", body); + sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + // Also test materialize on db[0] (where body already matches) + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_materialize: materialize on db[0] failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body0 || strcmp(body0, "Alpha\nBravo\nCharlie\nDelta\nEcho") != 0) { + printf("block_materialize: body0 mismatch: %s\n", body0 ? body0 : "NULL"); + if (body0) sqlite3_free(body0); + goto fail; + } + sqlite3_free(body0); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +bool do_test_block_lww_empty_text(int nclients, bool print_result, bool cleanup_databases) { + // Test: INSERT with empty body creates a single empty block + sqlite3 *db[1] = {NULL}; + time_t timestamp = time(NULL); + int rc; + + db[0] = do_create_database_file(0, timestamp, test_counter++); + if (!db[0]) return false; + + rc = sqlite3_exec(db[0], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Insert empty text + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', '');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_empty: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Should have exactly 1 block (empty content) + int64_t block_count = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + if (block_count != 1) { + printf("block_empty: expected 1 block for empty text, got %" PRId64 "\n", block_count); + goto fail; + } + + // Insert NULL text + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc2', NULL);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_empty: INSERT NULL failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // NULL body should also create 1 block (treated as empty) + int64_t null_blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc2');"); + if (null_blocks != 1) { + printf("block_empty: expected 1 block for NULL text, got %" PRId64 "\n", null_blocks); + goto fail; + } + + // Update from empty to multi-line + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Line1\nLine2' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_empty: UPDATE from empty failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + int64_t updated_blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (updated_blocks != 2) { + printf("block_empty: expected 2 blocks after update from empty, got %" PRId64 "\n", updated_blocks); + goto fail; + } + + close_db(db[0]); + return true; + +fail: + if (db[0]) close_db(db[0]); + return false; +} + +bool do_test_block_lww_conflict(int nclients, bool print_result, bool cleanup_databases) { + // Test: Two sites edit the SAME line concurrently; LWW picks the later write + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0 inserts initial document + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Same\nMiddle\nEnd');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_conflict: INSERT db[0] failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Sync initial state: db[0] -> db[1] + if (!do_merge_values(db[0], db[1], false)) { printf("block_conflict: initial merge failed\n"); goto fail; } + + // Site 0: edit first line + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Site0\nMiddle\nEnd' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_conflict: UPDATE db[0] failed\n"); goto fail; } + + // Site 1: also edit first line (conflict!) + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Site1\nMiddle\nEnd' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_conflict: UPDATE db[1] failed\n"); goto fail; } + + // Sync both ways using row-by-row merge + if (!do_merge_values(db[0], db[1], true)) { printf("block_conflict: merge 0->1 failed\n"); goto fail; } + if (!do_merge_values(db[1], db[0], true)) { printf("block_conflict: merge 1->0 failed\n"); goto fail; } + + // Materialize on both databases to reconstruct body from blocks + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_conflict: materialize db[0] failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_conflict: materialize db[1] failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + // Both databases should converge (same value) + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1) { + printf("block_conflict: could not read body from databases\n"); + ok = false; + } else if (strcmp(body0, body1) != 0) { + printf("block_conflict: bodies don't match after sync:\n db[0]: %s\n db[1]: %s\n", body0, body1); + ok = false; + } else { + // Should contain either "Site0" or "Site1" (LWW picks one), plus unchanged lines + if (!strstr(body0, "Middle")) { + printf("block_conflict: missing 'Middle' in result: %s\n", body0); + ok = false; + } + if (!strstr(body0, "End")) { + printf("block_conflict: missing 'End' in result: %s\n", body0); + ok = false; + } + // One of the conflicting edits should win + if (!strstr(body0, "Site0") && !strstr(body0, "Site1")) { + printf("block_conflict: neither 'Site0' nor 'Site1' in result: %s\n", body0); + ok = false; + } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +bool do_test_block_lww_multi_update(int nclients, bool print_result, bool cleanup_databases) { + // Test: Multiple successive updates correctly maintain block state + sqlite3 *db[1] = {NULL}; + time_t timestamp = time(NULL); + int rc; + + db[0] = do_create_database_file(0, timestamp, test_counter++); + if (!db[0]) return false; + + rc = sqlite3_exec(db[0], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Insert initial text (3 lines) + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'A\nB\nC');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_multi: INSERT failed\n"); goto fail; } + + // Update 1: remove middle line (3 -> 2 blocks) + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'A\nC' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_multi: UPDATE 1 failed\n"); goto fail; } + + int64_t blocks1 = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + if (blocks1 != 2) { printf("block_multi: expected 2 blocks after update 1, got %" PRId64 "\n", blocks1); goto fail; } + + // Update 2: add two lines (2 -> 4 blocks) + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'A\nX\nC\nY' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_multi: UPDATE 2 failed\n"); goto fail; } + + int64_t blocks2 = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + if (blocks2 != 4) { printf("block_multi: expected 4 blocks after update 2, got %" PRId64 "\n", blocks2); goto fail; } + + // Update 3: change everything to a single line (4 -> 1 block) + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'SINGLE' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_multi: UPDATE 3 failed\n"); goto fail; } + + int64_t blocks3 = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks;"); + if (blocks3 != 1) { printf("block_multi: expected 1 block after update 3, got %" PRId64 "\n", blocks3); goto fail; } + + // Materialize and verify + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_multi: materialize failed\n"); goto fail; } + + char *body = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, "SINGLE") != 0) { + printf("block_multi: expected 'SINGLE', got '%s'\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + close_db(db[0]); + return true; + +fail: + if (db[0]) close_db(db[0]); + return false; +} + +bool do_test_block_lww_reinsert(int nclients, bool print_result, bool cleanup_databases) { + // Test: DELETE then re-INSERT recreates blocks properly + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert, delete, then re-insert with different content + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Old1\nOld2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_reinsert: initial INSERT failed\n"); goto fail; } + + rc = sqlite3_exec(db[0], "DELETE FROM docs WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_reinsert: DELETE failed\n"); goto fail; } + + // Block metadata should be dropped (blocks table entries are orphaned by design) + int64_t meta_after_del = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (meta_after_del != 0) { + printf("block_reinsert: expected 0 block metadata after delete, got %" PRId64 "\n", meta_after_del); + goto fail; + } + + // Re-insert with new content + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'New1\nNew2\nNew3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_reinsert: re-INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Check block metadata was recreated (3 new block entries) + int64_t meta_after_reinsert = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (meta_after_reinsert != 3) { + printf("block_reinsert: expected 3 block metadata after re-insert, got %" PRId64 "\n", meta_after_reinsert); + goto fail; + } + + // Sync to db[1] and verify + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("block_reinsert: merge failed\n"); goto fail; } + + // Materialize on db[1] + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("block_reinsert: materialize on db[1] failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body1 || strcmp(body1, "New1\nNew2\nNew3") != 0) { + printf("block_reinsert: body mismatch on db[1]: %s\n", body1 ? body1 : "NULL"); + if (body1) sqlite3_free(body1); + goto fail; + } + sqlite3_free(body1); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +bool do_test_block_lww_add_lines(int nclients, bool print_result, bool cleanup_databases) { + // Test: Both sites add lines at different positions; after sync, all lines are present + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0 inserts initial doc + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line1\nLine2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync initial: 0 -> 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) goto fail; + + // Site 0: append a line at the end + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Line1\nLine2\nAppended0' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: insert a line in the middle + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Line1\nInserted1\nLine2' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_using_payload(db[0], db[1], true, true)) goto fail; + if (!do_merge_using_payload(db[1], db[0], true, true)) goto fail; + + // Both should converge + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1) { + printf("block_add_lines: could not read body\n"); + ok = false; + } else if (strcmp(body0, body1) != 0) { + printf("block_add_lines: bodies don't match:\n db[0]: %s\n db[1]: %s\n", body0, body1); + ok = false; + } else { + // All original and added lines should be present + if (!strstr(body0, "Line1")) { printf("block_add_lines: missing Line1\n"); ok = false; } + if (!strstr(body0, "Line2")) { printf("block_add_lines: missing Line2\n"); ok = false; } + if (!strstr(body0, "Appended0")) { printf("block_add_lines: missing Appended0\n"); ok = false; } + if (!strstr(body0, "Inserted1")) { printf("block_add_lines: missing Inserted1\n"); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 1: Non-conflicting edits on different blocks — both edits preserved +bool do_test_block_lww_noconflict(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0 inserts initial document with 3 lines + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line1\nLine2\nLine3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync initial: 0 -> 1 + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: edit first line only + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'EditedByA\nLine2\nLine3' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: edit third line only (no conflict — different block) + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Line1\nLine2\nEditedByB' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + // Materialize on both + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("noconflict: bodies diverged: [%s] vs [%s]\n", body0 ? body0 : "NULL", body1 ? body1 : "NULL"); + ok = false; + } else { + // BOTH edits should be preserved (this is the key value of block-level LWW) + if (!strstr(body0, "EditedByA")) { printf("noconflict: missing EditedByA\n"); ok = false; } + if (!strstr(body0, "Line2")) { printf("noconflict: missing Line2\n"); ok = false; } + if (!strstr(body0, "EditedByB")) { printf("noconflict: missing EditedByB\n"); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 2: Concurrent add + edit — Site A adds a line, Site B modifies an existing line +bool do_test_block_lww_add_and_edit(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Initial doc + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Alpha\nBravo');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: add a new line at the end + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Alpha\nBravo\nCharlie' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: modify first line + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'AlphaEdited\nBravo' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("add_and_edit: bodies diverged: [%s] vs [%s]\n", body0 ? body0 : "NULL", body1 ? body1 : "NULL"); + ok = false; + } else { + // The added line and the edit should both be present + if (!strstr(body0, "Charlie")) { printf("add_and_edit: missing Charlie (added line)\n"); ok = false; } + if (!strstr(body0, "Bravo")) { printf("add_and_edit: missing Bravo\n"); ok = false; } + // First line: either AlphaEdited wins (from site 1) or Alpha (from site 0) — depends on LWW + // But the added line Charlie must survive regardless + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 3: Three-way sync — 3 databases with overlapping edits converge +bool do_test_block_lww_three_way(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[3] = {NULL, NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 3; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0 creates initial doc + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'L1\nL2\nL3\nL4');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync 0 -> 1, 0 -> 2 + if (!do_merge_values(db[0], db[1], false)) goto fail; + if (!do_merge_values(db[0], db[2], false)) goto fail; + + // Site 0: edit line 1 + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'S0\nL2\nL3\nL4' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: edit line 2 + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'L1\nS1\nL3\nL4' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 2: edit line 4 + rc = sqlite3_exec(db[2], "UPDATE docs SET body = 'L1\nL2\nL3\nS2' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Full mesh sync: each site sends to every other site + for (int src = 0; src < 3; src++) { + for (int dst = 0; dst < 3; dst++) { + if (src == dst) continue; + if (!do_merge_values(db[src], db[dst], true)) { printf("three_way: merge %d->%d failed\n", src, dst); goto fail; } + } + } + + // Materialize all + for (int i = 0; i < 3; i++) { + rc = sqlite3_exec(db[i], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("three_way: materialize db[%d] failed\n", i); goto fail; } + } + + // All three should converge + char *body[3]; + for (int i = 0; i < 3; i++) { + body[i] = do_select_text(db[i], "SELECT body FROM docs WHERE id = 'doc1';"); + } + + bool ok = true; + if (!body[0] || !body[1] || !body[2]) { printf("three_way: NULL body\n"); ok = false; } + else if (strcmp(body[0], body[1]) != 0 || strcmp(body[1], body[2]) != 0) { + printf("three_way: not converged:\n [0]: %s\n [1]: %s\n [2]: %s\n", body[0], body[1], body[2]); + ok = false; + } else { + // All three non-conflicting edits should be preserved + if (!strstr(body[0], "S0")) { printf("three_way: missing S0\n"); ok = false; } + if (!strstr(body[0], "S1")) { printf("three_way: missing S1\n"); ok = false; } + if (!strstr(body[0], "L3")) { printf("three_way: missing L3\n"); ok = false; } + if (!strstr(body[0], "S2")) { printf("three_way: missing S2\n"); ok = false; } + } + + for (int i = 0; i < 3; i++) { if (body[i]) sqlite3_free(body[i]); } + for (int i = 0; i < 3; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 3; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 4: Mixed block + normal columns — both work independently +bool do_test_block_lww_mixed_columns(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE notes (id TEXT NOT NULL PRIMARY KEY, body TEXT, title TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('notes');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + // body is block-level LWW, title is normal LWW + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Site 0: insert row with multi-line body and title + rc = sqlite3_exec(db[0], "INSERT INTO notes (id, body, title) VALUES ('n1', 'Line1\nLine2\nLine3', 'My Title');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync 0 -> 1 + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: edit block column (body line 1) AND normal column (title) + rc = sqlite3_exec(db[0], "UPDATE notes SET body = 'EditedLine1\nLine2\nLine3', title = 'Title From A' WHERE id = 'n1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: edit a different block (body line 3) AND normal column (title — will conflict via LWW) + rc = sqlite3_exec(db[1], "UPDATE notes SET body = 'Line1\nLine2\nEditedLine3', title = 'Title From B' WHERE id = 'n1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + // Materialize block column + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('notes', 'body', 'n1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('notes', 'body', 'n1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM notes WHERE id = 'n1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM notes WHERE id = 'n1';"); + char *title0 = do_select_text(db[0], "SELECT title FROM notes WHERE id = 'n1';"); + char *title1 = do_select_text(db[1], "SELECT title FROM notes WHERE id = 'n1';"); + + bool ok = true; + + // Bodies should converge + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("mixed_columns: body diverged\n"); + ok = false; + } else { + // Both non-conflicting block edits should be preserved + if (!strstr(body0, "EditedLine1")) { printf("mixed_columns: missing EditedLine1\n"); ok = false; } + if (!strstr(body0, "Line2")) { printf("mixed_columns: missing Line2\n"); ok = false; } + if (!strstr(body0, "EditedLine3")) { printf("mixed_columns: missing EditedLine3\n"); ok = false; } + } + + // Titles should converge (normal LWW — one wins) + if (!title0 || !title1 || strcmp(title0, title1) != 0) { + printf("mixed_columns: title diverged: [%s] vs [%s]\n", title0 ? title0 : "NULL", title1 ? title1 : "NULL"); + ok = false; + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + if (title0) sqlite3_free(title0); + if (title1) sqlite3_free(title1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 5: NULL to text transition — INSERT with NULL body, then UPDATE to multi-line text +bool do_test_block_lww_null_to_text(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert with NULL body on site 0 + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', NULL);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("null_to_text: INSERT NULL failed\n"); goto fail; } + + // Sync to site 1 + if (!do_merge_values(db[0], db[1], false)) { printf("null_to_text: initial sync failed\n"); goto fail; } + + // Update to multi-line text on site 0 + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Hello\nWorld\nFoo' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("null_to_text: UPDATE failed\n"); goto fail; } + + // Verify blocks created + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 3) { printf("null_to_text: expected 3 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync update to site 1 + if (!do_merge_values(db[0], db[1], true)) { printf("null_to_text: sync update failed\n"); goto fail; } + + // Materialize on site 1 + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("null_to_text: materialize failed\n"); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, "Hello\nWorld\nFoo") != 0) { + printf("null_to_text: expected 'Hello\\nWorld\\nFoo', got '%s'\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 6: Interleaved inserts — multiple rounds of inserting between existing lines +bool do_test_block_lww_interleaved(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Start with 2 lines + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'A\nB');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Round 1: Site 0 inserts between A and B + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'A\nC\nB' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], true)) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Round 2: Site 1 inserts between A and C + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'A\nD\nC\nB' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Round 3: Site 0 inserts between D and C + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'A\nD\nE\nC\nB' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], true)) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Verify final state on both sites + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("interleaved: diverged: [%s] vs [%s]\n", body0 ? body0 : "NULL", body1 ? body1 : "NULL"); + ok = false; + } else { + // All 5 lines should be present + if (!strstr(body0, "A")) { printf("interleaved: missing A\n"); ok = false; } + if (!strstr(body0, "D")) { printf("interleaved: missing D\n"); ok = false; } + if (!strstr(body0, "E")) { printf("interleaved: missing E\n"); ok = false; } + if (!strstr(body0, "C")) { printf("interleaved: missing C\n"); ok = false; } + if (!strstr(body0, "B")) { printf("interleaved: missing B\n"); ok = false; } + + // Verify 5 blocks + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 5) { printf("interleaved: expected 5 blocks, got %" PRId64 "\n", blocks); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 7: Custom delimiter — paragraph separator instead of newline +bool do_test_block_lww_custom_delimiter(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + // Set custom delimiter: double newline (paragraph separator) + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'delimiter', '\n\n');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("custom_delim: set delimiter failed: %s\n", sqlite3_errmsg(db[i])); goto fail; } + } + + // Insert text with double-newline separated paragraphs + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Para one line1\nline2\n\nPara two\n\nPara three');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Should produce 3 blocks (3 paragraphs) + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 3) { printf("custom_delim: expected 3 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync and materialize + if (!do_merge_values(db[0], db[1], false)) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, "Para one line1\nline2\n\nPara two\n\nPara three") != 0) { + printf("custom_delim: mismatch: [%s]\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 8: Large text — many lines to verify position ID distribution +bool do_test_block_lww_large_text(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Build a 200-line text + #define LARGE_NLINES 200 + char large_text[LARGE_NLINES * 20]; + int offset = 0; + for (int i = 0; i < LARGE_NLINES; i++) { + if (i > 0) large_text[offset++] = '\n'; + offset += snprintf(large_text + offset, sizeof(large_text) - offset, "Line %03d content", i); + } + + // Insert via prepared statement to avoid SQL escaping issues + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db[0], "INSERT INTO docs (id, body) VALUES ('bigdoc', ?);", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto fail; + sqlite3_bind_text(stmt, 1, large_text, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { printf("large_text: INSERT failed\n"); goto fail; } + + // Verify block count + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('bigdoc');"); + if (blocks != LARGE_NLINES) { printf("large_text: expected %d blocks, got %" PRId64 "\n", LARGE_NLINES, blocks); goto fail; } + + // Verify all position IDs are unique and ordered + int64_t distinct_positions = do_select_int(db[0], + "SELECT count(DISTINCT col_name) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + if (distinct_positions != LARGE_NLINES) { + printf("large_text: expected %d distinct positions, got %" PRId64 "\n", LARGE_NLINES, distinct_positions); + goto fail; + } + + // Sync and materialize + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("large_text: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'bigdoc');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("large_text: materialize failed\n"); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'bigdoc';"); + if (!body || strcmp(body, large_text) != 0) { + printf("large_text: roundtrip mismatch\n"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test 9: Rapid sequential updates — many updates on same row in quick succession +bool do_test_block_lww_rapid_updates(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert initial + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Start');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // 50 rapid updates, progressively adding lines + sqlite3_stmt *upd = NULL; + rc = sqlite3_prepare_v2(db[0], "UPDATE docs SET body = ? WHERE id = 'doc1';", -1, &upd, NULL); + if (rc != SQLITE_OK) goto fail; + + #define RAPID_ROUNDS 50 + char rapid_text[RAPID_ROUNDS * 20]; + int roff = 0; + for (int i = 0; i < RAPID_ROUNDS; i++) { + if (i > 0) rapid_text[roff++] = '\n'; + roff += snprintf(rapid_text + roff, sizeof(rapid_text) - roff, "Update%d", i); + + sqlite3_bind_text(upd, 1, rapid_text, roff, SQLITE_STATIC); + rc = sqlite3_step(upd); + if (rc != SQLITE_DONE) { printf("rapid: UPDATE %d failed\n", i); sqlite3_finalize(upd); goto fail; } + sqlite3_reset(upd); + } + sqlite3_finalize(upd); + + // Verify final block count matches line count + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != RAPID_ROUNDS) { + printf("rapid: expected %d blocks, got %" PRId64 "\n", RAPID_ROUNDS, blocks); + goto fail; + } + + // Sync and verify roundtrip + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("rapid: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("rapid: materialize failed\n"); goto fail; } + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("rapid: roundtrip mismatch\n"); + ok = false; + } else { + // Check first and last lines + if (!strstr(body0, "Update0")) { printf("rapid: missing Update0\n"); ok = false; } + if (!strstr(body0, "Update49")) { printf("rapid: missing Update49\n"); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Unicode/multibyte content in blocks (emoji, CJK, accented chars) +bool do_test_block_lww_unicode(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert multi-line text with unicode content + const char *unicode_text = "Hello \xC3\xA9\xC3\xA0\xC3\xBC" "\n" // accented: éàü + "\xE4\xB8\xAD\xE6\x96\x87\xE6\xB5\x8B\xE8\xAF\x95" "\n" // CJK: 中文测试 + "\xF0\x9F\x98\x80\xF0\x9F\x8E\x89\xF0\x9F\x9A\x80"; // emoji: 😀🎉🚀 + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', ?);", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto fail; + sqlite3_bind_text(stmt, 1, unicode_text, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) goto fail; + + // Should have 3 blocks + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 3) { printf("unicode: expected 3 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync and materialize + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("unicode: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("unicode: materialize failed\n"); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, unicode_text) != 0) { + printf("unicode: roundtrip mismatch\n"); + if (body) sqlite3_free(body); + goto fail; + } + + // Update: edit the emoji line + const char *updated_text = "Hello \xC3\xA9\xC3\xA0\xC3\xBC" "\n" + "\xE4\xB8\xAD\xE6\x96\x87\xE6\xB5\x8B\xE8\xAF\x95" "\n" + "\xF0\x9F\x92\xAF\xF0\x9F\x94\xA5"; // changed emoji: 💯🔥 + sqlite3_free(body); + + stmt = NULL; + rc = sqlite3_prepare_v2(db[0], "UPDATE docs SET body = ? WHERE id = 'doc1';", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto fail; + sqlite3_bind_text(stmt, 1, updated_text, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) goto fail; + + // Sync update + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("unicode: sync update failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, updated_text) != 0) { + printf("unicode: update roundtrip mismatch\n"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Special characters (tabs, carriage returns, etc.) in blocks +bool do_test_block_lww_special_chars(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Text with tabs, carriage returns, and other special chars within lines + const char *special_text = "col1\tcol2\tcol3\n" // tabs within line + "line with\r\nembedded\n" // \r before \n delimiter + "back\\slash \"quotes\""; // backslash and quotes + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', ?);", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto fail; + sqlite3_bind_text(stmt, 1, special_text, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) goto fail; + + // Should split on \n: "col1\tcol2\tcol3", "line with\r", "embedded", "back\\slash \"quotes\"" + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 4) { printf("special: expected 4 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync and verify roundtrip + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("special: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, special_text) != 0) { + printf("special: roundtrip mismatch\n"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Concurrent delete vs edit on different blocks +// Site A deletes the row, Site B edits a line. After sync, delete wins. +bool do_test_block_lww_delete_vs_edit(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert initial doc + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line1\nLine2\nLine3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync to site 1 + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: DELETE the row + rc = sqlite3_exec(db[0], "DELETE FROM docs WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: Edit line 2 + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Line1\nEdited\nLine3' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + // Both should converge: either row deleted or row exists with some content + int64_t rows0 = do_select_int(db[0], "SELECT count(*) FROM docs WHERE id = 'doc1';"); + int64_t rows1 = do_select_int(db[1], "SELECT count(*) FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (rows0 != rows1) { + printf("delete_vs_edit: row count diverged: db0=%" PRId64 " db1=%" PRId64 "\n", rows0, rows1); + ok = false; + } + + // If the row still exists, materialize and verify convergence + if (rows0 > 0 && rows1 > 0) { + sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (body0 && body1 && strcmp(body0, body1) != 0) { + printf("delete_vs_edit: bodies diverged\n"); + ok = false; + } + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + } + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Two block columns on same table +bool do_test_block_lww_two_block_cols(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT, notes TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'notes', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert with both block columns + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body, notes) VALUES ('doc1', 'B1\nB2\nB3', 'N1\nN2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("two_block_cols: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Verify blocks created for both columns + int64_t body_blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'body' || x'1f' || '%';"); + int64_t notes_blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync WHERE col_name LIKE 'notes' || x'1f' || '%';"); + if (body_blocks != 3) { printf("two_block_cols: expected 3 body blocks, got %" PRId64 "\n", body_blocks); goto fail; } + if (notes_blocks != 2) { printf("two_block_cols: expected 2 notes blocks, got %" PRId64 "\n", notes_blocks); goto fail; } + + // Sync to site 1 + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: edit body line 1 + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'B1_edited\nB2\nB3' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: edit notes line 2 + rc = sqlite3_exec(db[1], "UPDATE docs SET notes = 'N1\nN2_edited' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync both ways + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + // Materialize both columns on both sites + for (int i = 0; i < 2; i++) { + rc = sqlite3_exec(db[i], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("two_block_cols: materialize body db[%d] failed\n", i); goto fail; } + rc = sqlite3_exec(db[i], "SELECT cloudsync_text_materialize('docs', 'notes', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("two_block_cols: materialize notes db[%d] failed\n", i); goto fail; } + } + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + char *notes0 = do_select_text(db[0], "SELECT notes FROM docs WHERE id = 'doc1';"); + char *notes1 = do_select_text(db[1], "SELECT notes FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("two_block_cols: body diverged\n"); ok = false; + } else if (!strstr(body0, "B1_edited")) { + printf("two_block_cols: body edit missing\n"); ok = false; + } + + if (!notes0 || !notes1 || strcmp(notes0, notes1) != 0) { + printf("two_block_cols: notes diverged\n"); ok = false; + } else if (!strstr(notes0, "N2_edited")) { + printf("two_block_cols: notes edit missing\n"); ok = false; + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + if (notes0) sqlite3_free(notes0); + if (notes1) sqlite3_free(notes1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Update text to NULL (text->NULL transition) +bool do_test_block_lww_text_to_null(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert multi-line text + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line1\nLine2\nLine3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + int64_t blocks_before = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks_before != 3) { printf("text_to_null: expected 3 blocks before, got %" PRId64 "\n", blocks_before); goto fail; } + + // Update to NULL + rc = sqlite3_exec(db[0], "UPDATE docs SET body = NULL WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("text_to_null: UPDATE to NULL failed\n"); goto fail; } + + // Verify body is NULL + int64_t is_null = do_select_int(db[0], "SELECT body IS NULL FROM docs WHERE id = 'doc1';"); + if (is_null != 1) { printf("text_to_null: body not NULL after update\n"); goto fail; } + + // Sync and verify + if (!do_merge_values(db[0], db[1], false)) { printf("text_to_null: sync failed\n"); goto fail; } + + // Materialize on site 1 + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + int64_t is_null_b = do_select_int(db[1], "SELECT body IS NULL FROM docs WHERE id = 'doc1';"); + if (is_null_b != 1) { printf("text_to_null: body not NULL on site 1 after sync\n"); goto fail; } + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Payload-based sync for block columns (vs row-by-row do_merge_values) +bool do_test_block_lww_payload_sync(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert and first sync via payload + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Alpha\nBravo\nCharlie');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("payload_sync: initial merge failed\n"); goto fail; } + + // Edit on both sites + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Alpha_A\nBravo\nCharlie' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Alpha\nBravo\nCharlie_B' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync via payload both ways + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("payload_sync: merge 0->1 failed\n"); goto fail; } + if (!do_merge_using_payload(db[1], db[0], true, true)) { printf("payload_sync: merge 1->0 failed\n"); goto fail; } + + // Materialize + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("payload_sync: bodies diverged\n"); ok = false; + } else { + if (!strstr(body0, "Alpha_A")) { printf("payload_sync: missing Alpha_A\n"); ok = false; } + if (!strstr(body0, "Bravo")) { printf("payload_sync: missing Bravo\n"); ok = false; } + if (!strstr(body0, "Charlie_B")) { printf("payload_sync: missing Charlie_B\n"); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Idempotent apply — applying the same payload twice is a no-op +bool do_test_block_lww_idempotent(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert and sync + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Line1\nLine2\nLine3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_using_payload(db[0], db[1], false, true)) goto fail; + + // Edit on site 0 + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Edited1\nLine2\nLine3' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Apply payload to site 1 TWICE + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("idempotent: first apply failed\n"); goto fail; } + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("idempotent: second apply failed\n"); goto fail; } + + // Materialize + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + bool ok = true; + if (!body || strcmp(body, "Edited1\nLine2\nLine3") != 0) { + printf("idempotent: body mismatch: [%s]\n", body ? body : "NULL"); + ok = false; + } + + // Verify block count is still 3 (no duplicates from double apply) + int64_t blocks = do_select_int(db[1], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 3) { printf("idempotent: expected 3 blocks, got %" PRId64 "\n", blocks); ok = false; } + + if (body) sqlite3_free(body); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Block position ordering — after edits, materialized text has correct line order +bool do_test_block_lww_ordering(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert initial doc: A B C D E + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'A\nB\nC\nD\nE');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: insert X between B and C, remove D -> A B X C E + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'A\nB\nX\nC\nE' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: insert Y between D and E -> A B C D Y E + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'A\nB\nC\nD\nY\nE' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("ordering: bodies diverged: [%s] vs [%s]\n", body0 ? body0 : "NULL", body1 ? body1 : "NULL"); + ok = false; + } else { + // Verify ordering: A must come before B, B before C, etc. + // All lines that survived should maintain relative order + const char *pA = strstr(body0, "A"); + const char *pB = strstr(body0, "B"); + const char *pC = strstr(body0, "C"); + const char *pE = strstr(body0, "E"); + + if (!pA || !pB || !pC || !pE) { + printf("ordering: missing original lines\n"); ok = false; + } else { + if (pA >= pB) { printf("ordering: A not before B\n"); ok = false; } + if (pB >= pC) { printf("ordering: B not before C\n"); ok = false; } + if (pC >= pE) { printf("ordering: C not before E\n"); ok = false; } + } + + // X (inserted between B and C) should appear between B and C + const char *pX = strstr(body0, "X"); + if (pX) { + if (pX <= pB || pX >= pC) { printf("ordering: X not between B and C\n"); ok = false; } + } + + // Y should appear somewhere after C + const char *pY = strstr(body0, "Y"); + if (pY) { + if (pY <= pC) { printf("ordering: Y not after C\n"); ok = false; } + } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Composite primary key (text + int) with block column +bool do_test_block_lww_composite_pk(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (owner TEXT NOT NULL, seq INTEGER NOT NULL, body TEXT, PRIMARY KEY(owner, seq));", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert on site 0 + rc = sqlite3_exec(db[0], "INSERT INTO docs (owner, seq, body) VALUES ('alice', 1, 'Line1\nLine2\nLine3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("composite_pk: INSERT failed\n"); goto fail; } + + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('alice', 1);"); + if (blocks != 3) { printf("composite_pk: expected 3 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync to site 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("composite_pk: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'alice', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("composite_pk: materialize failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE owner = 'alice' AND seq = 1;"); + if (!body || strcmp(body, "Line1\nLine2\nLine3") != 0) { + printf("composite_pk: body mismatch: [%s]\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + // Edit on site 1, sync back + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'Line1\nEdited2\nLine3' WHERE owner = 'alice' AND seq = 1;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_using_payload(db[1], db[0], true, true)) { printf("composite_pk: reverse sync failed\n"); goto fail; } + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'alice', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE owner = 'alice' AND seq = 1;"); + if (!body0 || strcmp(body0, "Line1\nEdited2\nLine3") != 0) { + printf("composite_pk: reverse body mismatch: [%s]\n", body0 ? body0 : "NULL"); + if (body0) sqlite3_free(body0); + goto fail; + } + sqlite3_free(body0); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Empty string body (not NULL) — should produce 1 block with empty content +bool do_test_block_lww_empty_vs_null(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert empty string (NOT NULL) + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', '');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 1) { printf("empty_vs_null: expected 1 block for empty string, got %" PRId64 "\n", blocks); goto fail; } + + // Insert NULL + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc2', NULL);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + int64_t blocks_null = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc2');"); + if (blocks_null != 1) { printf("empty_vs_null: expected 1 block for NULL, got %" PRId64 "\n", blocks_null); goto fail; } + + // Sync both to site 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("empty_vs_null: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // doc1 (empty string): body should be empty string, NOT NULL + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + int64_t is_null1 = do_select_int(db[1], "SELECT body IS NULL FROM docs WHERE id = 'doc1';"); + if (is_null1 != 0) { printf("empty_vs_null: doc1 body should NOT be NULL\n"); if (body1) sqlite3_free(body1); goto fail; } + if (!body1 || strcmp(body1, "") != 0) { printf("empty_vs_null: doc1 body should be empty, got [%s]\n", body1 ? body1 : "NULL"); if (body1) sqlite3_free(body1); goto fail; } + sqlite3_free(body1); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: DELETE row then re-insert with different block content (resurrection) +bool do_test_block_lww_delete_reinsert(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert and sync + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'Old1\nOld2\nOld3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_using_payload(db[0], db[1], false, true)) goto fail; + + // Delete the row + rc = sqlite3_exec(db[0], "DELETE FROM docs WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("del_reinsert: DELETE failed\n"); goto fail; } + + // Sync delete + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("del_reinsert: delete sync failed\n"); goto fail; } + + // Verify row gone on site 1 + int64_t count = do_select_int(db[1], "SELECT count(*) FROM docs WHERE id = 'doc1';"); + if (count != 0) { printf("del_reinsert: row should be deleted on site 1, count=%" PRId64 "\n", count); goto fail; } + + // Re-insert with different content + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'New1\nNew2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("del_reinsert: re-INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + // Sync re-insert + if (!do_merge_using_payload(db[0], db[1], true, true)) { printf("del_reinsert: reinsert sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, "New1\nNew2") != 0) { + printf("del_reinsert: body mismatch after reinsert: [%s]\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: INTEGER primary key with block column +bool do_test_block_lww_integer_pk(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE notes (id INTEGER NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: CREATE TABLE failed on %d: %s\n", i, sqlite3_errmsg(db[i])); goto fail; } + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('notes', 'CLS', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: init failed on %d: %s\n", i, sqlite3_errmsg(db[i])); goto fail; } + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('notes', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: set_column failed on %d: %s\n", i, sqlite3_errmsg(db[i])); goto fail; } + } + + // Insert on site 0 + rc = sqlite3_exec(db[0], "INSERT INTO notes (id, body) VALUES (42, 'First\nSecond\nThird');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: INSERT failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM notes_cloudsync_blocks WHERE pk = cloudsync_pk_encode(42);"); + if (blocks != 3) { printf("int_pk: expected 3 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync to site 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("int_pk: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('notes', 'body', 42);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: materialize failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + + // Debug: check row exists + int64_t row_count = do_select_int(db[1], "SELECT count(*) FROM notes WHERE id = 42;"); + if (row_count != 1) { printf("int_pk: row not found on site 1, count=%" PRId64 "\n", row_count); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM notes WHERE id = 42;"); + if (!body || strcmp(body, "First\nSecond\nThird") != 0) { + printf("int_pk: body mismatch: [%s]\n", body ? body : "NULL"); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + // Edit and sync back + rc = sqlite3_exec(db[1], "UPDATE notes SET body = 'First\nEdited\nThird' WHERE id = 42;", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: UPDATE failed: %s\n", sqlite3_errmsg(db[1])); goto fail; } + if (!do_merge_using_payload(db[1], db[0], true, true)) { printf("int_pk: reverse sync failed\n"); goto fail; } + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('notes', 'body', 42);", NULL, NULL, NULL); + if (rc != SQLITE_OK) { printf("int_pk: reverse mat failed: %s\n", sqlite3_errmsg(db[0])); goto fail; } + + char *body0 = do_select_text(db[0], "SELECT body FROM notes WHERE id = 42;"); + if (!body0 || strcmp(body0, "First\nEdited\nThird") != 0) { + printf("int_pk: reverse body mismatch: [%s]\n", body0 ? body0 : "NULL"); + if (body0) sqlite3_free(body0); + goto fail; + } + sqlite3_free(body0); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Multiple rows with block columns in a single sync +bool do_test_block_lww_multi_row(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert 3 rows + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('r1', 'R1-Line1\nR1-Line2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('r2', 'R2-Alpha\nR2-Beta\nR2-Gamma');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('r3', 'R3-Only');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Edit r1 and r3 + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'R1-Edited\nR1-Line2' WHERE id = 'r1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'R3-Changed' WHERE id = 'r3';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Sync all in one payload + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("multi_row: sync failed\n"); goto fail; } + + // Materialize all + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'r1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'r2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'r3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + bool ok = true; + char *b1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'r1';"); + if (!b1 || strcmp(b1, "R1-Edited\nR1-Line2") != 0) { printf("multi_row: r1 mismatch [%s]\n", b1 ? b1 : "NULL"); ok = false; } + if (b1) sqlite3_free(b1); + + char *b2 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'r2';"); + if (!b2 || strcmp(b2, "R2-Alpha\nR2-Beta\nR2-Gamma") != 0) { printf("multi_row: r2 mismatch [%s]\n", b2 ? b2 : "NULL"); ok = false; } + if (b2) sqlite3_free(b2); + + char *b3 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'r3';"); + if (!b3 || strcmp(b3, "R3-Changed") != 0) { printf("multi_row: r3 mismatch [%s]\n", b3 ? b3 : "NULL"); ok = false; } + if (b3) sqlite3_free(b3); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Concurrent add at non-overlapping positions (top vs bottom) +bool do_test_block_lww_nonoverlap_add(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Insert initial: A B C + rc = sqlite3_exec(db[0], "INSERT INTO docs (id, body) VALUES ('doc1', 'A\nB\nC');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + if (!do_merge_values(db[0], db[1], false)) goto fail; + + // Site 0: add line at top -> X A B C + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'X\nA\nB\nC' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Site 1: add line at bottom -> A B C Y + rc = sqlite3_exec(db[1], "UPDATE docs SET body = 'A\nB\nC\nY' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + // Bidirectional sync + if (!do_merge_values(db[0], db[1], true)) goto fail; + if (!do_merge_values(db[1], db[0], true)) goto fail; + + rc = sqlite3_exec(db[0], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body0 = do_select_text(db[0], "SELECT body FROM docs WHERE id = 'doc1';"); + char *body1 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + + bool ok = true; + if (!body0 || !body1 || strcmp(body0, body1) != 0) { + printf("nonoverlap: bodies diverged: [%s] vs [%s]\n", body0 ? body0 : "NULL", body1 ? body1 : "NULL"); + ok = false; + } else { + // X should be present, Y should be present, original A B C should be present + if (!strstr(body0, "X")) { printf("nonoverlap: X missing\n"); ok = false; } + if (!strstr(body0, "Y")) { printf("nonoverlap: Y missing\n"); ok = false; } + if (!strstr(body0, "A")) { printf("nonoverlap: A missing\n"); ok = false; } + if (!strstr(body0, "B")) { printf("nonoverlap: B missing\n"); ok = false; } + if (!strstr(body0, "C")) { printf("nonoverlap: C missing\n"); ok = false; } + + // Order: X before A, Y after C + const char *pX = strstr(body0, "X"); + const char *pA = strstr(body0, "A"); + const char *pC = strstr(body0, "C"); + const char *pY = strstr(body0, "Y"); + if (pX && pA && pX >= pA) { printf("nonoverlap: X not before A\n"); ok = false; } + if (pC && pY && pY <= pC) { printf("nonoverlap: Y not after C\n"); ok = false; } + } + + if (body0) sqlite3_free(body0); + if (body1) sqlite3_free(body1); + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return ok; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Very long single line (10K chars, single block) +bool do_test_block_lww_long_line(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Build a 10,000-char single line + { + char *long_line = (char *)malloc(10001); + if (!long_line) goto fail; + for (int i = 0; i < 10000; i++) long_line[i] = 'A' + (i % 26); + long_line[10000] = '\0'; + + char *sql = sqlite3_mprintf("INSERT INTO docs (id, body) VALUES ('doc1', '%q');", long_line); + rc = sqlite3_exec(db[0], sql, NULL, NULL, NULL); + sqlite3_free(sql); + + if (rc != SQLITE_OK) { printf("long_line: INSERT failed: %s\n", sqlite3_errmsg(db[0])); free(long_line); goto fail; } + + // Should have 1 block (no newlines) + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 1) { printf("long_line: expected 1 block, got %" PRId64 "\n", blocks); free(long_line); goto fail; } + + // Sync to site 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("long_line: sync failed\n"); free(long_line); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) { free(long_line); goto fail; } + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + bool match = (body && strcmp(body, long_line) == 0); + if (!match) printf("long_line: body mismatch (len=%zu vs expected 10000)\n", body ? strlen(body) : 0); + if (body) sqlite3_free(body); + free(long_line); + if (!match) goto fail; + } + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + +// Test: Whitespace and empty lines (delimiter edge cases) +bool do_test_block_lww_whitespace(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[2] = {NULL, NULL}; + time_t timestamp = time(NULL); + int rc; + + for (int i = 0; i < 2; i++) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + rc = sqlite3_exec(db[i], "CREATE TABLE docs (id TEXT NOT NULL PRIMARY KEY, body TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('docs');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_column('docs', 'body', 'algo', 'block');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + } + + // Text with empty lines, whitespace-only lines, trailing newline + const char *text = "Line1\n\n spaces \n\t\ttabs\n\nLine6\n"; + char *sql = sqlite3_mprintf("INSERT INTO docs (id, body) VALUES ('doc1', '%q');", text); + rc = sqlite3_exec(db[0], sql, NULL, NULL, NULL); + sqlite3_free(sql); + if (rc != SQLITE_OK) { printf("whitespace: INSERT failed\n"); goto fail; } + + // Count blocks: "Line1", "", " spaces ", "\t\ttabs", "", "Line6", "" (trailing newline produces empty last block) + int64_t blocks = do_select_int(db[0], "SELECT count(*) FROM docs_cloudsync_blocks WHERE pk = cloudsync_pk_encode('doc1');"); + if (blocks != 7) { printf("whitespace: expected 7 blocks, got %" PRId64 "\n", blocks); goto fail; } + + // Sync to site 1 + if (!do_merge_using_payload(db[0], db[1], false, true)) { printf("whitespace: sync failed\n"); goto fail; } + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body || strcmp(body, text) != 0) { + printf("whitespace: body mismatch: [%s] vs [%s]\n", body ? body : "NULL", text); + if (body) sqlite3_free(body); + goto fail; + } + sqlite3_free(body); + + // Edit: remove empty lines -> "Line1\n spaces \n\t\ttabs\nLine6" + rc = sqlite3_exec(db[0], "UPDATE docs SET body = 'Line1\n spaces \n\t\ttabs\nLine6' WHERE id = 'doc1';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + if (!do_merge_using_payload(db[0], db[1], true, true)) goto fail; + rc = sqlite3_exec(db[1], "SELECT cloudsync_text_materialize('docs', 'body', 'doc1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto fail; + + char *body2 = do_select_text(db[1], "SELECT body FROM docs WHERE id = 'doc1';"); + if (!body2 || strcmp(body2, "Line1\n spaces \n\t\ttabs\nLine6") != 0) { + printf("whitespace: body2 mismatch: [%s]\n", body2 ? body2 : "NULL"); + if (body2) sqlite3_free(body2); + goto fail; + } + sqlite3_free(body2); + + for (int i = 0; i < 2; i++) { close_db(db[i]); db[i] = NULL; } + return true; + +fail: + for (int i = 0; i < 2; i++) if (db[i]) close_db(db[i]); + return false; +} + int test_report(const char *description, bool result){ printf("%-30s %s\n", description, (result) ? "OK" : "FAILED"); return result ? 0 : 1; @@ -7976,6 +10270,43 @@ int main (int argc, const char * argv[]) { // test row-level filter result += test_report("Test Row Filter:", do_test_row_filter(2, print_result, cleanup_databases)); + // test block-level LWW + result += test_report("Test Block LWW Insert:", do_test_block_lww_insert(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Update:", do_test_block_lww_update(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Sync:", do_test_block_lww_sync(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Delete:", do_test_block_lww_delete(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Materialize:", do_test_block_lww_materialize(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Empty:", do_test_block_lww_empty_text(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Conflict:", do_test_block_lww_conflict(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Multi-Update:", do_test_block_lww_multi_update(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Reinsert:", do_test_block_lww_reinsert(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Add Lines:", do_test_block_lww_add_lines(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW NoConflict:", do_test_block_lww_noconflict(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Add+Edit:", do_test_block_lww_add_and_edit(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Three-Way:", do_test_block_lww_three_way(3, print_result, cleanup_databases)); + result += test_report("Test Block LWW MixedCols:", do_test_block_lww_mixed_columns(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW NULL->Text:", do_test_block_lww_null_to_text(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Interleave:", do_test_block_lww_interleaved(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW CustomDelim:", do_test_block_lww_custom_delimiter(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Large Text:", do_test_block_lww_large_text(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Rapid Upd:", do_test_block_lww_rapid_updates(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Unicode:", do_test_block_lww_unicode(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW SpecialChars:", do_test_block_lww_special_chars(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Del vs Edit:", do_test_block_lww_delete_vs_edit(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW TwoBlockCols:", do_test_block_lww_two_block_cols(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Text->NULL:", do_test_block_lww_text_to_null(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW PayloadSync:", do_test_block_lww_payload_sync(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Idempotent:", do_test_block_lww_idempotent(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Ordering:", do_test_block_lww_ordering(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW CompositePK:", do_test_block_lww_composite_pk(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW EmptyVsNull:", do_test_block_lww_empty_vs_null(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW DelReinsert:", do_test_block_lww_delete_reinsert(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW IntegerPK:", do_test_block_lww_integer_pk(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW MultiRow:", do_test_block_lww_multi_row(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW NonOverlap:", do_test_block_lww_nonoverlap_add(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW LongLine:", do_test_block_lww_long_line(2, print_result, cleanup_databases)); + result += test_report("Test Block LWW Whitespace:", do_test_block_lww_whitespace(2, print_result, cleanup_databases)); + finalize: if (rc != SQLITE_OK) printf("%s (%d)\n", (db) ? sqlite3_errmsg(db) : "N/A", rc); close_db(db);