Skip to content

Commit d6bb396

Browse files
authored
fix: actionable error when API called before cloudsync_init (#47)
Six functions leaked misleading low-level symptoms when called before any cloudsync_init('<table_name>'): cloudsync_changes -> "out of memory (7)" cloudsync_db_version -> "Unable to retrieve db_version (not an error)" cloudsync_db_version_next -> same pattern (SQLite) / silent -1 (PG) cloudsync_set_filter -> 10+ "no such table" NOTICEs + generic trigger error cloudsync_clear_filter -> same as set_filter cloudsync_payload_apply -> 3x "no such table: cloudsync_settings" debug lines followed by "Runtime error: not an error" Add cloudsync_context_is_initialized() helper and guard each function on its error branch. When the root cause is missing init, raise a single message pointing at SELECT cloudsync_init('<table_name>'). The guard is a NULL-pointer check on the error branch only, so the sync hot path (cloudsync_db_version_next stepped by merge triggers on every received row, cloudsync_payload_apply on every inbound sync) is unaffected. cloudsync_changes on PostgreSQL was already graceful (empty result set), so no fix was needed there. Regression test in test/unit.c matches the stable substring "cloudsync_init" rather than full text, so future rewordings do not break it. Bumps to 1.0.17.
1 parent 94ef456 commit d6bb396

7 files changed

Lines changed: 204 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

7+
## [1.0.17] - 2026-04-24
8+
9+
### Fixed
10+
11+
- **Confusing errors when `cloudsync_init` was never called**: `cloudsync_changes` (SQLite), `cloudsync_db_version`, `cloudsync_db_version_next`, `cloudsync_set_filter`, `cloudsync_clear_filter`, and `cloudsync_payload_apply` now raise a single actionable message pointing at `SELECT cloudsync_init('<table_name>')` instead of leaking low-level symptoms (`out of memory`, `not an error`, silent `-1`, multi-line "no such table" dumps). The guard runs only on the error branch, so the sync hot path is unaffected.
12+
713
## [1.0.16] - 2026-04-16
814

915
### Fixed

src/cloudsync.c

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,6 +2341,16 @@ bool cloudsync_config_exists (cloudsync_context *data) {
23412341
return database_internal_table_exists(data, CLOUDSYNC_SITEID_NAME) == true;
23422342
}
23432343

2344+
bool cloudsync_context_is_initialized (cloudsync_context *data) {
2345+
// A fully initialized context has its persistent "is the DB stale" probe
2346+
// prepared. cloudsync_context_init prepares data_version_stmt (via
2347+
// cloudsync_add_dbvms) only after the cloudsync_site_id table exists, so
2348+
// a non-NULL pointer means cloudsync_init has been called at least once
2349+
// on this connection. Used to produce actionable error messages when
2350+
// callers hit a function before calling cloudsync_init.
2351+
return data != NULL && data->data_version_stmt != NULL;
2352+
}
2353+
23442354
cloudsync_context *cloudsync_context_create (void *db) {
23452355
cloudsync_context *data = (cloudsync_context *)cloudsync_memory_zeroalloc((uint64_t)(sizeof(cloudsync_context)));
23462356
if (!data) return NULL;
@@ -3201,6 +3211,19 @@ static int cloudsync_payload_decode_callback (void *xdata, int index, int type,
32013211
// #ifndef CLOUDSYNC_OMIT_RLS_VALIDATION
32023212

32033213
int cloudsync_payload_apply (cloudsync_context *data, const char *payload, int blen, int *pnrows) {
3214+
// Guard against calling payload_apply before cloudsync_init: without this,
3215+
// the settings lookups at the top of this function would each emit a
3216+
// "no such table: cloudsync_settings" debug line, control would fall
3217+
// through to the meta-table insert, and the function would ultimately
3218+
// return an error with an empty errmsg — SQLite then surfaces that as
3219+
// the confusing "Runtime error: not an error".
3220+
if (!cloudsync_context_is_initialized(data)) {
3221+
return cloudsync_set_error(data,
3222+
"cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') "
3223+
"to enable sync on a table before calling cloudsync_payload_apply().",
3224+
DBRES_MISUSE);
3225+
}
3226+
32043227
// sanity check
32053228
if (blen < (int)sizeof(cloudsync_payload_header)) return cloudsync_set_error(data, "Error on cloudsync_payload_apply: invalid payload length", DBRES_MISUSE);
32063229

src/cloudsync.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
extern "C" {
1919
#endif
2020

21-
#define CLOUDSYNC_VERSION "1.0.16"
21+
#define CLOUDSYNC_VERSION "1.0.17"
2222
#define CLOUDSYNC_MAX_TABLENAME_LEN 512
2323

2424
#define CLOUDSYNC_VALUE_NOTSET -1
@@ -64,6 +64,7 @@ int64_t cloudsync_dbversion (cloudsync_context *data);
6464
void cloudsync_update_schema_hash (cloudsync_context *data);
6565
int cloudsync_dbversion_check_uptodate (cloudsync_context *data);
6666
bool cloudsync_config_exists (cloudsync_context *data);
67+
bool cloudsync_context_is_initialized (cloudsync_context *data);
6768
dbvm_t *cloudsync_colvalue_stmt (cloudsync_context *data, const char *tbl_name, bool *persistent);
6869

6970
// CloudSync alter table

src/postgresql/cloudsync_postgresql.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ Datum cloudsync_db_version (PG_FUNCTION_ARGS) {
218218
{
219219
int rc = cloudsync_dbversion_check_uptodate(data);
220220
if (rc != DBRES_OK) {
221+
// When cloudsync_init was never called, data_version_stmt is NULL
222+
// and database_errmsg() is empty, producing the unhelpful "Unable
223+
// to retrieve db_version ()". Detect the uninitialized state and
224+
// return an actionable message instead. The extra check only runs
225+
// on the error branch, so it costs nothing on the sync hot path.
226+
if (!cloudsync_context_is_initialized(data)) {
227+
ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
228+
errmsg("cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') to enable sync on a table before calling cloudsync_db_version().")));
229+
}
221230
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("Unable to retrieve db_version (%s)", database_errmsg(data))));
222231
}
223232

@@ -256,6 +265,18 @@ Datum cloudsync_db_version_next (PG_FUNCTION_ARGS) {
256265
PG_TRY();
257266
{
258267
next_version = cloudsync_dbversion_next(data, merging_version);
268+
if (next_version == -1) {
269+
// Previously this path silently returned -1, which is worse than
270+
// an error because callers cannot distinguish a bogus version
271+
// number from a real one. Emit an actionable message when the
272+
// root cause is that cloudsync_init has never been called.
273+
if (!cloudsync_context_is_initialized(data)) {
274+
ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
275+
errmsg("cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') to enable sync on a table before calling cloudsync_db_version_next().")));
276+
}
277+
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
278+
errmsg("Unable to retrieve next_db_version (%s)", database_errmsg(data))));
279+
}
259280
}
260281
PG_CATCH();
261282
{
@@ -670,6 +691,16 @@ Datum cloudsync_set_filter (PG_FUNCTION_ARGS) {
670691

671692
PG_TRY();
672693
{
694+
// Guard against calling set_filter before the target table has been
695+
// set up for sync: without this, we'd drop and fail to recreate
696+
// triggers, emitting ten+ noisy "does not exist, skipping" NOTICEs
697+
// followed by a generic "error recreating triggers" message that
698+
// does not point at the real cause.
699+
if (!cloudsync_context_is_initialized(data) || !table_lookup(data, tbl)) {
700+
ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
701+
errmsg("cloudsync_set_filter: table '%s' is not configured for sync. Call SELECT cloudsync_init('%s') first.", tbl, tbl)));
702+
}
703+
673704
// Store filter in table settings
674705
dbutils_table_settings_set_key_value(data, tbl, "*", "filter", filter_expr);
675706

@@ -735,6 +766,14 @@ Datum cloudsync_clear_filter (PG_FUNCTION_ARGS) {
735766

736767
PG_TRY();
737768
{
769+
// Guard against calling clear_filter before the target table has
770+
// been set up for sync — see cloudsync_set_filter for the same
771+
// rationale.
772+
if (!cloudsync_context_is_initialized(data) || !table_lookup(data, tbl)) {
773+
ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
774+
errmsg("cloudsync_clear_filter: table '%s' is not configured for sync. Call SELECT cloudsync_init('%s') first.", tbl, tbl)));
775+
}
776+
738777
// Remove filter from settings
739778
dbutils_table_settings_set_key_value(data, tbl, "*", "filter", NULL);
740779

src/sqlite/cloudsync_changes_sqlite.c

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,12 +395,26 @@ int cloudsync_changesvtab_best_index (sqlite3_vtab *vtab, sqlite3_index_info *id
395395

396396
int cloudsync_changesvtab_filter (sqlite3_vtab_cursor *cursor, int idxn, const char *idxs, int argc, sqlite3_value **argv) {
397397
DEBUG_VTAB("cloudsync_changesvtab_filter");
398-
398+
399399
cloudsync_changes_cursor *c = (cloudsync_changes_cursor *)cursor;
400400
cloudsync_context *data = c->vtab->data;
401401
sqlite3 *db = c->vtab->db;
402402
char *sql = vtab_build_changes_sql(data, idxs);
403-
if (sql == NULL) return SQLITE_NOMEM;
403+
if (sql == NULL) {
404+
// vtab_build_changes_sql returns NULL when no *_cloudsync meta-tables
405+
// exist (cloudsync_init was never called, or the last configured table
406+
// was cleaned up): the inner GROUP_CONCAT produces a NULL row and the
407+
// outer SELECT yields a NULL final string. Distinguish this from a
408+
// genuine OOM by checking whether cloudsync is configured, so the user
409+
// gets an actionable message instead of "out of memory".
410+
if (!cloudsync_config_exists(data) || dbutils_table_settings_count_tables(data) == 0) {
411+
return vtab_set_error((sqlite3_vtab *)c->vtab,
412+
"cloudsync has no tables configured for sync. Call "
413+
"SELECT cloudsync_init('<table_name>') to enable sync on a "
414+
"table before querying cloudsync_changes.");
415+
}
416+
return SQLITE_NOMEM;
417+
}
404418

405419
// the xFilter method may be called multiple times on the same sqlite3_vtab_cursor*
406420
if (c->vm) sqlite3_finalize(c->vm);

src/sqlite/cloudsync_sqlite.c

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,32 +77,51 @@ void dbsync_db_version (sqlite3_context *context, int argc, sqlite3_value **argv
7777
DEBUG_FUNCTION("cloudsync_db_version");
7878
UNUSED_PARAMETER(argc);
7979
UNUSED_PARAMETER(argv);
80-
80+
8181
// retrieve context
8282
cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context);
83-
83+
8484
int rc = cloudsync_dbversion_check_uptodate(data);
8585
if (rc != SQLITE_OK) {
86-
dbsync_set_error(context, "Unable to retrieve db_version (%s).", database_errmsg(data));
86+
// When cloudsync_init was never called, data_version_stmt is NULL and
87+
// database_errmsg() falls back to "not an error", producing the
88+
// confusing "Unable to retrieve db_version (not an error)". Detect the
89+
// uninitialized state and return an actionable message instead. The
90+
// extra check only runs on the error branch, so it costs nothing on
91+
// the sync hot path (merge operations keep going through the normal
92+
// path where rc == SQLITE_OK).
93+
if (!cloudsync_context_is_initialized(data)) {
94+
dbsync_set_error(context,
95+
"cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') "
96+
"to enable sync on a table before calling cloudsync_db_version().");
97+
} else {
98+
dbsync_set_error(context, "Unable to retrieve db_version (%s).", database_errmsg(data));
99+
}
87100
return;
88101
}
89-
102+
90103
sqlite3_result_int64(context, cloudsync_dbversion(data));
91104
}
92105

93106
void dbsync_db_version_next (sqlite3_context *context, int argc, sqlite3_value **argv) {
94107
DEBUG_FUNCTION("cloudsync_db_version_next");
95-
108+
96109
// retrieve context
97110
cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context);
98-
111+
99112
sqlite3_int64 merging_version = (argc == 1) ? database_value_int(argv[0]) : CLOUDSYNC_VALUE_NOTSET;
100113
sqlite3_int64 value = cloudsync_dbversion_next(data, merging_version);
101114
if (value == -1) {
102-
dbsync_set_error(context, "Unable to retrieve next_db_version (%s).", database_errmsg(data));
115+
if (!cloudsync_context_is_initialized(data)) {
116+
dbsync_set_error(context,
117+
"cloudsync is not initialized: call SELECT cloudsync_init('<table_name>') "
118+
"to enable sync on a table before calling cloudsync_db_version_next().");
119+
} else {
120+
dbsync_set_error(context, "Unable to retrieve next_db_version (%s).", database_errmsg(data));
121+
}
103122
return;
104123
}
105-
124+
106125
sqlite3_result_int64(context, value);
107126
}
108127

@@ -1243,6 +1262,17 @@ void dbsync_set_filter (sqlite3_context *context, int argc, sqlite3_value **argv
12431262

12441263
cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context);
12451264

1265+
// Guard against calling set_filter before the target table has been set
1266+
// up for sync: without this, we'd hit "no such table:
1267+
// cloudsync_table_settings" or "no such table: main.<tbl>" deep inside
1268+
// the trigger recreation path, which is not actionable.
1269+
if (!cloudsync_context_is_initialized(data) || !table_lookup(data, tbl)) {
1270+
dbsync_set_error(context,
1271+
"cloudsync_set_filter: table '%s' is not configured for sync. "
1272+
"Call SELECT cloudsync_init('%s') first.", tbl, tbl);
1273+
return;
1274+
}
1275+
12461276
// Store filter in table settings
12471277
dbutils_table_settings_set_key_value(data, tbl, "*", "filter", filter_expr);
12481278

@@ -1281,6 +1311,15 @@ void dbsync_clear_filter (sqlite3_context *context, int argc, sqlite3_value **ar
12811311

12821312
cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context);
12831313

1314+
// Guard against calling clear_filter before the target table has been set
1315+
// up for sync — see dbsync_set_filter for the same rationale.
1316+
if (!cloudsync_context_is_initialized(data) || !table_lookup(data, tbl)) {
1317+
dbsync_set_error(context,
1318+
"cloudsync_clear_filter: table '%s' is not configured for sync. "
1319+
"Call SELECT cloudsync_init('%s') first.", tbl, tbl);
1320+
return;
1321+
}
1322+
12841323
// Remove filter from table settings (set to NULL/empty)
12851324
dbutils_table_settings_set_key_value(data, tbl, "*", "filter", NULL);
12861325

test/unit.c

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2493,6 +2493,76 @@ static int deny_sqlite_master_authorizer(void *pUserData, int action, const char
24932493
return SQLITE_OK;
24942494
}
24952495

2496+
// Run a SQL statement expected to fail, and verify that its error message
2497+
// contains the stable substring "cloudsync_init" — which every uninitialized
2498+
// guard added by the fix points the caller at. Matching a single stable token
2499+
// rather than full text tolerates future rewordings of the user-facing string.
2500+
static bool expect_uninit_error (sqlite3 *db, const char *sql) {
2501+
sqlite3_stmt *stmt = NULL;
2502+
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
2503+
if (rc != SQLITE_OK) {
2504+
// Prepare-time failures also land in sqlite3_errmsg — accept those.
2505+
const char *m = sqlite3_errmsg(db);
2506+
bool ok = (m != NULL && strstr(m, "cloudsync_init") != NULL);
2507+
if (stmt) sqlite3_finalize(stmt);
2508+
return ok;
2509+
}
2510+
rc = sqlite3_step(stmt);
2511+
bool failed = (rc != SQLITE_ROW && rc != SQLITE_DONE);
2512+
const char *msg = sqlite3_errmsg(db);
2513+
bool has_hint = (msg != NULL && strstr(msg, "cloudsync_init") != NULL);
2514+
sqlite3_finalize(stmt);
2515+
return failed && has_hint;
2516+
}
2517+
2518+
// Regression test for the "uninit error messages" fix. Every function that
2519+
// previously leaked a misleading low-level symptom (out of memory, "not an
2520+
// error", silent -1, multi-line "no such table" dumps) must now point the
2521+
// caller at SELECT cloudsync_init(...). Match on a stable substring rather
2522+
// than full text so the test tolerates future rewordings.
2523+
bool do_test_uninit_error_messages (void) {
2524+
sqlite3 *db = NULL;
2525+
bool result = false;
2526+
2527+
if (sqlite3_open(":memory:", &db) != SQLITE_OK) return false;
2528+
if (sqlite3_cloudsync_init(db, NULL, NULL) != SQLITE_OK) goto cleanup;
2529+
2530+
// 96 bytes of zero padding — bigger than cloudsync_payload_header, so the
2531+
// size sanity check in dbsync_payload_decode passes and control reaches
2532+
// our guard inside cloudsync_payload_apply.
2533+
const char *payload_sql =
2534+
"SELECT cloudsync_payload_apply(zeroblob(96));";
2535+
2536+
if (!expect_uninit_error(db, "SELECT * FROM cloudsync_changes;")) goto cleanup;
2537+
if (!expect_uninit_error(db, "SELECT cloudsync_db_version();")) goto cleanup;
2538+
if (!expect_uninit_error(db, "SELECT cloudsync_db_version_next();")) goto cleanup;
2539+
if (!expect_uninit_error(db, "SELECT cloudsync_set_filter('foo','1=1');")) goto cleanup;
2540+
if (!expect_uninit_error(db, "SELECT cloudsync_clear_filter('foo');")) goto cleanup;
2541+
if (!expect_uninit_error(db, payload_sql)) goto cleanup;
2542+
2543+
// Happy path: after cloudsync_init the same functions no longer fail with
2544+
// the uninitialized hint. cloudsync_db_version must now return a value.
2545+
if (sqlite3_exec(db,
2546+
"CREATE TABLE t (id TEXT PRIMARY KEY NOT NULL, v TEXT);"
2547+
"SELECT cloudsync_init('t');",
2548+
NULL, NULL, NULL) != SQLITE_OK) goto cleanup;
2549+
2550+
sqlite3_stmt *stmt = NULL;
2551+
if (sqlite3_prepare_v2(db, "SELECT cloudsync_db_version();", -1, &stmt, NULL) != SQLITE_OK) goto cleanup;
2552+
int step_rc = sqlite3_step(stmt);
2553+
sqlite3_finalize(stmt);
2554+
if (step_rc != SQLITE_ROW) goto cleanup;
2555+
2556+
result = true;
2557+
2558+
cleanup:
2559+
if (db) {
2560+
sqlite3_exec(db, "SELECT cloudsync_terminate();", NULL, NULL, NULL);
2561+
sqlite3_close(db);
2562+
}
2563+
return result;
2564+
}
2565+
24962566
// Verify that cloudsync_dbversion_rebuild surfaces a real failure from
24972567
// database_select_text(SQL_DBVERSION_BUILD_QUERY, ...) instead of silently
24982568
// treating it as "no *_cloudsync meta-tables present" — which would leave
@@ -12425,6 +12495,7 @@ int main (int argc, const char * argv[]) {
1242512495
result += test_report("Stale Table Settings:", do_test_stale_table_settings(cleanup_databases));
1242612496
result += test_report("Stale Table Settings Dropped Meta:", do_test_stale_table_settings_dropped_meta(cleanup_databases));
1242712497
result += test_report("DBVersion Rebuild Error:", do_test_dbversion_rebuild_error());
12498+
result += test_report("Uninit Error Messages:", do_test_uninit_error_messages());
1242812499
result += test_report("Block LWW Existing Data:", do_test_block_lww_existing_data(cleanup_databases));
1242912500
result += test_report("Block Column Reload:", do_test_block_column_reload(cleanup_databases));
1243012501
result += test_report("CB Error Cleanup:", do_test_context_cb_error_cleanup());

0 commit comments

Comments
 (0)