Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/commands/test-sync-roundtrip-postgres-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Ask the user to provide a DDL query for the table(s) to test. It can be in Postg
**Option 1: Simple TEXT primary key**
```sql
CREATE TABLE test_sync (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
name TEXT,
value INTEGER
);
Expand All @@ -34,13 +34,13 @@ CREATE TABLE test_uuid (
**Option 3: Two tables scenario (tests multi-table sync)**
```sql
CREATE TABLE authors (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
name TEXT,
email TEXT
);

CREATE TABLE books (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
title TEXT,
author_id TEXT,
published_year INTEGER
Expand Down
6 changes: 3 additions & 3 deletions .claude/commands/test-sync-roundtrip-postrges-local-rls.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Ask the user to provide a DDL query for the table(s) to test. It can be in Postg
**Option 1: Simple TEXT primary key with user_id for RLS**
```sql
CREATE TABLE test_sync (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id UUID NOT NULL,
name TEXT,
value INTEGER
Expand All @@ -36,14 +36,14 @@ CREATE TABLE test_uuid (
**Option 3: Two tables scenario with user ownership**
```sql
CREATE TABLE authors (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id UUID NOT NULL,
name TEXT,
email TEXT
);

CREATE TABLE books (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id UUID NOT NULL,
title TEXT,
author_id TEXT,
Expand Down
6 changes: 3 additions & 3 deletions .claude/commands/test-sync-roundtrip-sqlitecloud-rls.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Ask the user to provide a DDL query for the table(s) to test. It can be in Postg
**Option 1: Simple TEXT primary key with user_id for RLS**
```sql
CREATE TABLE test_sync (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT,
value INTEGER
Expand All @@ -26,14 +26,14 @@ CREATE TABLE test_sync (
**Option 2: Two tables scenario with user ownership**
```sql
CREATE TABLE authors (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT,
email TEXT
);

CREATE TABLE books (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT,
author_id TEXT,
Expand Down
2 changes: 1 addition & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ This document provides a reference for the SQLite functions provided by the `sql

Before initialization, `cloudsync_init` performs schema sanity checks to ensure compatibility with CRDT requirements and best practices. These checks include:
- Primary keys should not be auto-incrementing integers; GUIDs (UUIDs, ULIDs) are highly recommended to prevent multi-node collisions.
- All primary key columns must be `NOT NULL`.
- All non-primary key `NOT NULL` columns must have a `DEFAULT` value.
- **Note:** Any write operation that includes a NULL value for a primary key column will be rejected with an error, even if SQLite would normally allow it due to a legacy behavior.

**Schema Design Considerations:**

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ sqlite3 myapp.db

-- Create a table (primary key MUST be TEXT for global uniqueness)
CREATE TABLE IF NOT EXISTS my_data (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Expand Down Expand Up @@ -313,7 +313,7 @@ SELECT cloudsync_terminate();
-- Load extension and create identical table structure
.load ./cloudsync
CREATE TABLE IF NOT EXISTS my_data (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Expand Down Expand Up @@ -372,12 +372,12 @@ When designing your database schema for SQLite Sync, follow these best practices
- **Use globally unique identifiers**: Always use TEXT primary keys with UUIDs, ULIDs, or similar globally unique identifiers
- **Avoid auto-incrementing integers**: Integer primary keys can cause conflicts across multiple devices
- **Use `cloudsync_uuid()`**: The built-in function generates UUIDv7 identifiers optimized for distributed systems
- **All primary keys must be explicitly declared as `NOT NULL`**.
- **Note:** Any write operation that includes a NULL value for a primary key column will be rejected with an error, even if SQLite would normally allow it due to a legacy behavior.

```sql
-- ✅ Recommended: Globally unique TEXT primary key
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL, -- Use cloudsync_uuid()
id TEXT PRIMARY KEY, -- Use cloudsync_uuid()
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
Expand Down
16 changes: 8 additions & 8 deletions docs/postgresql/CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,26 @@ so CloudSync can sync between a PostgreSQL server and SQLite clients.

### 1) Primary Keys

- Use **TEXT NOT NULL** primary keys in SQLite.
- PostgreSQL primary keys can be **TEXT NOT NULL** or **UUID**. If the PK type
- Use **TEXT** primary keys in SQLite.
- PostgreSQL primary keys can be **TEXT** or **UUID**. If the PK type
isn't explicitly mapped to a DBTYPE (like UUID), it will be converted to TEXT
in the payload so it remains compatible with the SQLite extension.
- Generate IDs with `cloudsync_uuid()` on both sides.
- Avoid INTEGER auto-increment PKs.

SQLite:
```sql
id TEXT PRIMARY KEY NOT NULL
id TEXT PRIMARY KEY
```

PostgreSQL:
```sql
id TEXT PRIMARY KEY NOT NULL
id TEXT PRIMARY KEY
```

PostgreSQL (UUID):
```sql
id UUID PRIMARY KEY NOT NULL
id UUID PRIMARY KEY
```

### 2) NOT NULL Columns Must Have DEFAULTs
Expand Down Expand Up @@ -99,7 +99,7 @@ Use defaults that serialize the same on both sides:
SQLite:
```sql
CREATE TABLE notes (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
body TEXT DEFAULT '',
views INTEGER NOT NULL DEFAULT 0,
Expand All @@ -111,7 +111,7 @@ CREATE TABLE notes (
PostgreSQL:
```sql
CREATE TABLE notes (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
body TEXT DEFAULT '',
views INTEGER NOT NULL DEFAULT 0,
Expand All @@ -136,7 +136,7 @@ SELECT cloudsync_init('notes');

### Checklist

- [ ] PKs are TEXT + NOT NULL
- [ ] PKs are TEXT (or UUID in PostgreSQL)
- [ ] All NOT NULL columns have DEFAULT
- [ ] Only INTEGER/FLOAT/TEXT/BLOB-compatible types
- [ ] Same column names and order
Expand Down
6 changes: 3 additions & 3 deletions docs/postgresql/RLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Given a table with an ownership column (`user_id`):

```sql
CREATE TABLE documents (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
user_id UUID,
title TEXT,
content TEXT
Expand Down Expand Up @@ -68,13 +68,13 @@ This example shows the complete flow of syncing data between two databases where
```sql
-- Source database (DB A) — no RLS, represents the sync server
CREATE TABLE documents (
id TEXT PRIMARY KEY NOT NULL, user_id UUID, title TEXT, content TEXT
id TEXT PRIMARY KEY, user_id UUID, title TEXT, content TEXT
);
SELECT cloudsync_init('documents');

-- Target database (DB B) — RLS enforced
CREATE TABLE documents (
id TEXT PRIMARY KEY NOT NULL, user_id UUID, title TEXT, content TEXT
id TEXT PRIMARY KEY, user_id UUID, title TEXT, content TEXT
);
SELECT cloudsync_init('documents');
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
Expand Down
2 changes: 1 addition & 1 deletion docs/postgresql/SUPABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ SELECT cloudsync_version();

```sql
CREATE TABLE notes (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
body TEXT DEFAULT ''
);

Expand Down
6 changes: 3 additions & 3 deletions examples/simple-todo-db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Tables must be created on both the local database and SQLite Cloud with identica
-- Create the main tasks table
-- Note: Primary key MUST be TEXT (not INTEGER) for global uniqueness
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
userid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT DEFAULT '',
Expand All @@ -84,7 +84,7 @@ SELECT cloudsync_is_enabled('tasks');
- Execute the same CREATE TABLE statement:
```sql
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
userid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT DEFAULT '',
Expand Down Expand Up @@ -149,7 +149,7 @@ sqlite3 todo_device_b.db
```sql
-- Create identical table structure
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
id TEXT PRIMARY KEY,
userid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT DEFAULT '',
Expand Down
2 changes: 2 additions & 0 deletions src/cloudsync.c
Original file line number Diff line number Diff line change
Expand Up @@ -2986,6 +2986,7 @@ int cloudsync_table_sanity_check (cloudsync_context *data, const char *name, boo
}

// if user declared explicit primary key(s) then make sure they are all declared as NOT NULL
#if CLOUDSYNC_CHECK_NOTNULL_PRIKEYS
if (npri_keys > 0) {
int npri_keys_notnull = database_count_pk(data, name, true, cloudsync_schema(data));
if (npri_keys_notnull < 0) return cloudsync_set_dberror(data);
Expand All @@ -2994,6 +2995,7 @@ int cloudsync_table_sanity_check (cloudsync_context *data, const char *name, boo
return cloudsync_set_error(data, buffer, DBRES_ERROR);
}
}
#endif

// check for columns declared as NOT NULL without a DEFAULT value.
// Otherwise, col_merge_stmt would fail if changes to other columns are inserted first.
Expand Down
2 changes: 1 addition & 1 deletion src/cloudsync.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
extern "C" {
#endif

#define CLOUDSYNC_VERSION "0.9.115"
#define CLOUDSYNC_VERSION "0.9.116"
#define CLOUDSYNC_MAX_TABLENAME_LEN 512

#define CLOUDSYNC_VALUE_NOTSET -1
Expand Down
11 changes: 10 additions & 1 deletion src/pk.c
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
#define DATABASE_TYPE_MAX_NEGATIVE_INTEGER 6 // was SQLITE_MAX_NEGATIVE_INTEGER
#define DATABASE_TYPE_NEGATIVE_FLOAT 7 // was SQLITE_NEGATIVE_FLOAT

char * const PRIKEY_NULL_CONSTRAINT_ERROR = "PRIKEY_NULL_CONSTRAINT_ERROR";

// MARK: - Public Callbacks -

int pk_decode_bind_callback (void *xdata, int index, int type, int64_t ival, double dval, char *pval) {
Expand Down Expand Up @@ -436,7 +438,14 @@ char *pk_encode (dbvalue_t **argv, int argc, char *b, bool is_prikey, size_t *bs
if (!bsize) return NULL;
// must fit in a single byte
if (argc > 255) return NULL;


// if schema does not enforce NOT NULL on primary keys, check at runtime
#ifndef CLOUDSYNC_CHECK_NOTNULL_PRIKEYS
for (int i = 0; i < argc; i++) {
if (database_value_type(argv[i]) == DBTYPE_NULL) return PRIKEY_NULL_CONSTRAINT_ERROR;
}
#endif

// 1 is the number of items in the serialization
// always 1 byte so max 255 primary keys, even if there is an hard SQLite limit of 128
size_t blen_curr = *bsize;
Expand Down
2 changes: 2 additions & 0 deletions src/pk.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

typedef int (*pk_decode_callback) (void *xdata, int index, int type, int64_t ival, double dval, char *pval);

extern char * const PRIKEY_NULL_CONSTRAINT_ERROR;

char *pk_encode_prikey (dbvalue_t **argv, int argc, char *b, size_t *bsize);
char *pk_encode_value (dbvalue_t *value, size_t *bsize);
char *pk_encode (dbvalue_t **argv, int argc, char *b, bool is_prikey, size_t *bsize, int skip_idx);
Expand Down
14 changes: 13 additions & 1 deletion src/postgresql/cloudsync_postgresql.c
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ Datum cloudsync_pk_encode (PG_FUNCTION_ARGS) {

size_t pklen = 0;
char *encoded = pk_encode_prikey((dbvalue_t **)argv, argc, NULL, &pklen);
if (!encoded) {
if (!encoded || encoded == PRIKEY_NULL_CONSTRAINT_ERROR) {
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("cloudsync_pk_encode failed to encode primary key")));
}

Expand Down Expand Up @@ -1271,6 +1271,10 @@ Datum cloudsync_insert (PG_FUNCTION_ARGS) {
if (!cleanup.pk) {
ereport(ERROR, (errcode(ERRCODE_OUT_OF_MEMORY), errmsg("Not enough memory to encode the primary key(s)")));
}
if (cleanup.pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
cleanup.pk = NULL;
ereport(ERROR, (errcode(ERRCODE_NOT_NULL_VIOLATION), errmsg("Insert aborted because primary key in table %s contains NULL values", table_name)));
}

// Compute the next database version for tracking changes
int64_t db_version = cloudsync_dbversion_next(data, CLOUDSYNC_VALUE_NOTSET);
Expand Down Expand Up @@ -1360,6 +1364,10 @@ Datum cloudsync_delete (PG_FUNCTION_ARGS) {
if (!cleanup.pk) {
ereport(ERROR, (errcode(ERRCODE_OUT_OF_MEMORY), errmsg("Not enough memory to encode the primary key(s)")));
}
if (cleanup.pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
cleanup.pk = NULL;
ereport(ERROR, (errcode(ERRCODE_NOT_NULL_VIOLATION), errmsg("Delete aborted because primary key in table %s contains NULL values", table_name)));
}

int64_t db_version = cloudsync_dbversion_next(data, CLOUDSYNC_VALUE_NOTSET);

Expand Down Expand Up @@ -1561,6 +1569,10 @@ Datum cloudsync_update_finalfn (PG_FUNCTION_ARGS) {
if (!pk) {
ereport(ERROR, (errcode(ERRCODE_OUT_OF_MEMORY), errmsg("Not enough memory to encode the primary key(s)")));
}
if (pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
pk = NULL;
ereport(ERROR, (errcode(ERRCODE_NOT_NULL_VIOLATION), errmsg("Update aborted because primary key in table %s contains NULL values", table_name)));
}
if (prikey_changed) {
oldpk = pk_encode_prikey((dbvalue_t **)payload->old_values, pk_count, buffer2, &oldpklen);
if (!oldpk) {
Expand Down
17 changes: 16 additions & 1 deletion src/sqlite/cloudsync_sqlite.c
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ void dbsync_col_value (sqlite3_context *context, int argc, sqlite3_value **argv)
void dbsync_pk_encode (sqlite3_context *context, int argc, sqlite3_value **argv) {
size_t bsize = 0;
char *buffer = pk_encode_prikey((dbvalue_t **)argv, argc, NULL, &bsize);
if (!buffer) {
if (!buffer || buffer == PRIKEY_NULL_CONSTRAINT_ERROR) {
sqlite3_result_null(context);
return;
}
Expand Down Expand Up @@ -347,6 +347,10 @@ void dbsync_insert (sqlite3_context *context, int argc, sqlite3_value **argv) {
sqlite3_result_error(context, "Not enough memory to encode the primary key(s).", -1);
return;
}
if (pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
dbsync_set_error(context, "Insert aborted because primary key in table %s contains NULL values.", table_name);
return;
}

// compute the next database version for tracking changes
int64_t db_version = cloudsync_dbversion_next(data, CLOUDSYNC_VALUE_NOTSET);
Expand Down Expand Up @@ -407,6 +411,11 @@ void dbsync_delete (sqlite3_context *context, int argc, sqlite3_value **argv) {
return;
}

if (pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
dbsync_set_error(context, "Delete aborted because primary key in table %s contains NULL values.", table_name);
return;
}

// mark the row as deleted by inserting a delete sentinel into the metadata
rc = local_mark_delete_meta(table, pk, pklen, db_version, cloudsync_bumpseq(data));
if (rc != SQLITE_OK) goto cleanup;
Expand Down Expand Up @@ -542,6 +551,11 @@ void dbsync_update_final (sqlite3_context *context) {
dbsync_update_payload_free(payload);
return;
}
if (pk == PRIKEY_NULL_CONSTRAINT_ERROR) {
dbsync_set_error(context, "Update aborted because primary key in table %s contains NULL values.", table_name);
dbsync_update_payload_free(payload);
return;
}

if (prikey_changed) {
// if the primary key has changed, we need to handle the row differently:
Expand All @@ -551,6 +565,7 @@ void dbsync_update_final (sqlite3_context *context) {
// encode the OLD primary key into a buffer
oldpk = pk_encode_prikey((dbvalue_t **)payload->old_values, table_count_pks(table), buffer2, &oldpklen);
if (!oldpk) {
// no check here about PRIKEY_NULL_CONSTRAINT_ERROR because by design oldpk cannot contain NULL values
if (pk != buffer) cloudsync_memory_free(pk);
sqlite3_result_error(context, "Not enough memory to encode the primary key(s).", -1);
dbsync_update_payload_free(payload);
Expand Down
Loading
Loading