From b2cc1dce1eaf776b55a875a8ad43c910f9764ed6 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Wed, 15 Apr 2026 17:06:39 -0600 Subject: [PATCH 1/5] fix: surface send lastFailure and receive errors in sync JSON Propagate server-reported lastFailure through cloudsync_network_send_changes and cloudsync_network_sync output. Capture receive-phase errors (including cloudsync_payload_apply failures like unknown schema hash) and emit them as a structured receive.error field in cloudsync_network_sync instead of raising a SQL error that would hide the successful send result. cloudsync_network_check_changes keeps the error-raising behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/network/network.c | 229 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 196 insertions(+), 33 deletions(-) diff --git a/src/network/network.c b/src/network/network.c index 6660005..5e22363 100644 --- a/src/network/network.c +++ b/src/network/network.c @@ -415,27 +415,46 @@ int network_set_sqlite_result (sqlite3_context *context, NETWORK_RESULT *result) return rc; } -int network_download_changes (sqlite3_context *context, const char *download_url, int *pnrows) { +// If err_out is non-NULL, any error encountered here is returned via *err_out +// (malloc'd, caller must cloudsync_memory_free) instead of being raised on the +// sqlite3_context. This lets composite callers (cloudsync_network_sync) surface +// receive-side failures as structured JSON rather than SQL errors. +int network_download_changes (sqlite3_context *context, const char *download_url, int *pnrows, char **err_out) { DEBUG_FUNCTION("network_download_changes"); - + cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); network_data *netdata = (network_data *)cloudsync_auxdata(data); if (!netdata) { - sqlite3_result_error(context, "Unable to retrieve network CloudSync context.", -1); + const char *msg = "Unable to retrieve network CloudSync context."; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); return -1; } - + NETWORK_RESULT result = network_receive_buffer(netdata, download_url, NULL, false, false, NULL, NULL); - + int rc = SQLITE_OK; if (result.code == CLOUDSYNC_NETWORK_BUFFER) { rc = cloudsync_payload_apply(data, result.buffer, (int)result.blen, pnrows); + if (rc != DBRES_OK) { + const char *msg = cloudsync_errmsg(data); + if (!msg || !msg[0]) msg = "cloudsync_payload_apply failed"; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); + if (pnrows) *pnrows = 0; + } } else { - rc = network_set_sqlite_result(context, &result); + if (err_out) { + const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during receive"; + *err_out = cloudsync_string_dup(msg); + rc = -1; + } else { + rc = network_set_sqlite_result(context, &result); + } if (pnrows) *pnrows = 0; } network_result_cleanup(&result); - + return rc; } @@ -557,6 +576,68 @@ static int json_extract_array_size(const char *json, size_t json_len, const char return val->size; } +// Escape a string for safe embedding as a JSON string value (without surrounding quotes). +// Caller must free with cloudsync_memory_free. +static char *json_escape_string(const char *src) { + if (!src) return NULL; + size_t len = strlen(src); + // worst case: every char becomes \uXXXX (6 bytes) + char *out = cloudsync_memory_zeroalloc(len * 6 + 1); + if (!out) return NULL; + size_t j = 0; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)src[i]; + switch (c) { + case '"': out[j++] = '\\'; out[j++] = '"'; break; + case '\\': out[j++] = '\\'; out[j++] = '\\'; break; + case '\b': out[j++] = '\\'; out[j++] = 'b'; break; + case '\f': out[j++] = '\\'; out[j++] = 'f'; break; + case '\n': out[j++] = '\\'; out[j++] = 'n'; break; + case '\r': out[j++] = '\\'; out[j++] = 'r'; break; + case '\t': out[j++] = '\\'; out[j++] = 't'; break; + default: + if (c < 0x20) { + static const char hex[] = "0123456789abcdef"; + out[j++] = '\\'; out[j++] = 'u'; + out[j++] = '0'; out[j++] = '0'; + out[j++] = hex[(c >> 4) & 0xf]; + out[j++] = hex[c & 0xf]; + } else { + out[j++] = (char)c; + } + } + } + out[j] = '\0'; + return out; +} + +// Returns a malloc'd copy of the raw JSON substring for an object-valued key +// (found at any depth). Caller must free with cloudsync_memory_free. +static char *json_extract_object_raw(const char *json, size_t json_len, const char *key) { + if (!json || json_len == 0 || !key) return NULL; + + jsmn_parser parser; + jsmntok_t tokens[JSMN_MAX_TOKENS]; + jsmn_init(&parser); + int ntokens = jsmn_parse(&parser, json, json_len, tokens, JSMN_MAX_TOKENS); + if (ntokens < 1) return NULL; + + int i = jsmn_find_key(json, tokens, ntokens, key); + if (i < 0 || i + 1 >= ntokens) return NULL; + + jsmntok_t *val = &tokens[i + 1]; + if (val->type != JSMN_OBJECT) return NULL; + + int len = val->end - val->start; + if (len <= 0) return NULL; + + char *out = cloudsync_memory_zeroalloc(len + 1); + if (!out) return NULL; + memcpy(out, json + val->start, len); + out[len] = '\0'; + return out; +} + int network_extract_query_param (const char *query, const char *key, char *output, size_t output_size) { if (!query || !key || !output || output_size == 0) { return -1; // Invalid input @@ -804,6 +885,7 @@ typedef struct { const char *status; // computed status string int rows_received; // rows from check char *tables_json; // JSON array of affected table names, caller must cloudsync_memory_free + char *last_failure_json; // raw JSON object for server-reported lastFailure, caller must cloudsync_memory_free } sync_result; static const char *network_compute_status(int64_t last_optimistic, int64_t last_confirmed, @@ -931,12 +1013,14 @@ int cloudsync_network_send_changes_internal (sqlite3_context *context, int argc, int64_t last_optimistic_version = -1; int64_t last_confirmed_version = -1; int gaps_size = -1; + char *last_failure_json = NULL; if (res.code == CLOUDSYNC_NETWORK_BUFFER && res.buffer) { last_optimistic_version = json_extract_int(res.buffer, res.blen, "lastOptimisticVersion", -1); last_confirmed_version = json_extract_int(res.buffer, res.blen, "lastConfirmedVersion", -1); gaps_size = json_extract_array_size(res.buffer, res.blen, "gaps"); if (gaps_size < 0) gaps_size = 0; + last_failure_json = json_extract_object_raw(res.buffer, res.blen, "lastFailure"); } else if (res.code != CLOUDSYNC_NETWORK_OK) { network_result_to_sqlite_error(context, res, "cloudsync_network_send_changes unable to notify BLOB upload to remote host."); network_result_cleanup(&res); @@ -960,7 +1044,10 @@ int cloudsync_network_send_changes_internal (sqlite3_context *context, int argc, out->server_version = last_optimistic_version; out->local_version = new_db_version; out->status = network_compute_status(last_optimistic_version, last_confirmed_version, gaps_size, new_db_version); + out->last_failure_json = last_failure_json; + last_failure_json = NULL; } + if (last_failure_json) cloudsync_memory_free(last_failure_json); network_result_cleanup(&res); return SQLITE_OK; @@ -969,27 +1056,52 @@ int cloudsync_network_send_changes_internal (sqlite3_context *context, int argc, void cloudsync_network_send_changes (sqlite3_context *context, int argc, sqlite3_value **argv) { DEBUG_FUNCTION("cloudsync_network_send_changes"); - sync_result sr = {-1, 0, NULL, 0, NULL}; + sync_result sr = {-1, 0, NULL, 0, NULL, NULL}; int rc = cloudsync_network_send_changes_internal(context, argc, argv, &sr); - if (rc != SQLITE_OK) return; - - char buf[256]; - snprintf(buf, sizeof(buf), - "{\"send\":{\"status\":\"%s\",\"localVersion\":%" PRId64 ",\"serverVersion\":%" PRId64 "}}", - sr.status ? sr.status : "error", sr.local_version, sr.server_version); - sqlite3_result_text(context, buf, -1, SQLITE_TRANSIENT); + if (rc != SQLITE_OK) { if (sr.last_failure_json) cloudsync_memory_free(sr.last_failure_json); return; } + + char *buf; + if (sr.last_failure_json) { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld,\"lastFailure\":%s}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version, + sr.last_failure_json); + } else { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version); + } + sqlite3_result_text(context, buf, -1, cloudsync_memory_free); + if (sr.last_failure_json) cloudsync_memory_free(sr.last_failure_json); } -int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync_result *out) { +int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync_result *out, char **err_out) { cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); network_data *netdata = (network_data *)cloudsync_auxdata(data); - if (!netdata) {sqlite3_result_error(context, "Unable to retrieve CloudSync network context.", -1); return -1;} + if (!netdata) { + const char *msg = "Unable to retrieve CloudSync network context."; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); + return -1; + } int64_t db_version = dbutils_settings_get_int64_value(data, CLOUDSYNC_KEY_CHECK_DBVERSION); - if (db_version<0) {sqlite3_result_error(context, "Unable to retrieve db_version.", -1); return -1;} + if (db_version<0) { + const char *msg = "Unable to retrieve db_version."; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); + return -1; + } int seq = dbutils_settings_get_int_value(data, CLOUDSYNC_KEY_CHECK_SEQ); - if (seq<0) {sqlite3_result_error(context, "Unable to retrieve seq.", -1); return -1;} + if (seq<0) { + const char *msg = "Unable to retrieve seq."; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); + return -1; + } // Capture local db_version before download so we can query cloudsync_changes afterwards int64_t prev_dbv = cloudsync_dbversion(data); @@ -1002,14 +1114,22 @@ int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync if (result.code == CLOUDSYNC_NETWORK_BUFFER) { char *download_url = json_extract_string(result.buffer, result.blen, "url"); if (!download_url) { - sqlite3_result_error(context, "cloudsync_network_check_changes: missing 'url' in check response.", -1); + const char *msg = "cloudsync_network_check_changes: missing 'url' in check response."; + if (err_out) *err_out = cloudsync_string_dup(msg); + else sqlite3_result_error(context, msg, -1); network_result_cleanup(&result); return SQLITE_ERROR; } - rc = network_download_changes(context, download_url, pnrows); + rc = network_download_changes(context, download_url, pnrows, err_out); cloudsync_memory_free(download_url); } else { - rc = network_set_sqlite_result(context, &result); + if (err_out) { + const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during check"; + *err_out = cloudsync_string_dup(msg); + rc = -1; + } else { + rc = network_set_sqlite_result(context, &result); + } } if (out && pnrows) out->rows_received = *pnrows; @@ -1025,28 +1145,71 @@ int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync } void cloudsync_network_sync (sqlite3_context *context, int wait_ms, int max_retries) { - sync_result sr = {-1, 0, NULL, 0, NULL}; + sync_result sr = {-1, 0, NULL, 0, NULL, NULL}; int rc = cloudsync_network_send_changes_internal(context, 0, NULL, &sr); - if (rc != SQLITE_OK) return; + if (rc != SQLITE_OK) { if (sr.last_failure_json) cloudsync_memory_free(sr.last_failure_json); return; } int ntries = 0; int nrows = 0; + char *receive_err = NULL; while (ntries < max_retries) { if (ntries > 0) sqlite3_sleep(wait_ms); if (sr.tables_json) { cloudsync_memory_free(sr.tables_json); sr.tables_json = NULL; } - rc = cloudsync_network_check_internal(context, &nrows, &sr); - if (rc == SQLITE_OK && nrows > 0) break; + if (receive_err) { cloudsync_memory_free(receive_err); receive_err = NULL; } + rc = cloudsync_network_check_internal(context, &nrows, &sr, &receive_err); + // a receive error (network or apply) won't fix itself across retries + if (rc != SQLITE_OK) break; + if (nrows > 0) break; ntries++; } - if (rc != SQLITE_OK) { if (sr.tables_json) cloudsync_memory_free(sr.tables_json); return; } + + // If the receive phase failed, still emit structured JSON so the caller + // sees that the send phase completed and understands why receive did not. + if (rc != SQLITE_OK && !receive_err) { + receive_err = cloudsync_string_dup("receive failed"); + } + if (receive_err) { + rc = SQLITE_OK; + nrows = 0; + if (sr.tables_json) { cloudsync_memory_free(sr.tables_json); sr.tables_json = NULL; } + } const char *tables = sr.tables_json ? sr.tables_json : "[]"; - char *buf = cloudsync_memory_mprintf( - "{\"send\":{\"status\":\"%s\",\"localVersion\":%" PRId64 ",\"serverVersion\":%" PRId64 "}," - "\"receive\":{\"rows\":%d,\"tables\":%s}}", - sr.status ? sr.status : "error", sr.local_version, sr.server_version, nrows, tables); + char *escaped_err = receive_err ? json_escape_string(receive_err) : NULL; + char *buf; + if (sr.last_failure_json && escaped_err) { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld,\"lastFailure\":%s}," + "\"receive\":{\"rows\":%d,\"tables\":%s,\"error\":\"%s\"}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version, + sr.last_failure_json, nrows, tables, escaped_err); + } else if (sr.last_failure_json) { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld,\"lastFailure\":%s}," + "\"receive\":{\"rows\":%d,\"tables\":%s}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version, + sr.last_failure_json, nrows, tables); + } else if (escaped_err) { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld}," + "\"receive\":{\"rows\":%d,\"tables\":%s,\"error\":\"%s\"}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version, + nrows, tables, escaped_err); + } else { + buf = cloudsync_memory_mprintf( + "{\"send\":{\"status\":\"%s\",\"localVersion\":%lld,\"serverVersion\":%lld}," + "\"receive\":{\"rows\":%d,\"tables\":%s}}", + sr.status ? sr.status : "error", + (long long)sr.local_version, (long long)sr.server_version, nrows, tables); + } sqlite3_result_text(context, buf, -1, cloudsync_memory_free); + if (escaped_err) cloudsync_memory_free(escaped_err); + if (receive_err) cloudsync_memory_free(receive_err); if (sr.tables_json) cloudsync_memory_free(sr.tables_json); + if (sr.last_failure_json) cloudsync_memory_free(sr.last_failure_json); } void cloudsync_network_sync0 (sqlite3_context *context, int argc, sqlite3_value **argv) { @@ -1069,9 +1232,9 @@ void cloudsync_network_sync2 (sqlite3_context *context, int argc, sqlite3_value void cloudsync_network_check_changes (sqlite3_context *context, int argc, sqlite3_value **argv) { DEBUG_FUNCTION("cloudsync_network_check_changes"); - sync_result sr = {-1, 0, NULL, 0, NULL}; + sync_result sr = {-1, 0, NULL, 0, NULL, NULL}; int nrows = 0; - int rc = cloudsync_network_check_internal(context, &nrows, &sr); + int rc = cloudsync_network_check_internal(context, &nrows, &sr, NULL); if (rc != SQLITE_OK) { if (sr.tables_json) cloudsync_memory_free(sr.tables_json); return; } const char *tables = sr.tables_json ? sr.tables_json : "[]"; From 90ea33c32569b361394aa7ae8ed214f8adb1073b Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Wed, 15 Apr 2026 17:14:20 -0600 Subject: [PATCH 2/5] chore: bump to 1.0.15 and document sync error surfacing Co-Authored-By: Claude Opus 4.6 (1M context) --- API.md | 21 +++++++++++++++------ CHANGELOG.md | 12 ++++++++++++ src/cloudsync.h | 2 +- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/API.md b/API.md index dd47852..d2a57ed 100644 --- a/API.md +++ b/API.md @@ -490,18 +490,22 @@ SELECT cloudsync_network_set_apikey('your_api_key'); **Returns:** A JSON string with the send result: ```json -{"send": {"status": "synced|syncing|out-of-sync|error", "localVersion": N, "serverVersion": N}} +{"send": {"status": "synced|syncing|out-of-sync|error", "localVersion": N, "serverVersion": N, "lastFailure": {...}}} ``` - `send.status`: The current sync state — `"synced"` (all changes confirmed), `"syncing"` (changes sent but not yet confirmed), `"out-of-sync"` (local changes pending or gaps detected), or `"error"`. - `send.localVersion`: The latest local database version. - `send.serverVersion`: The latest version confirmed by the server. +- `send.lastFailure` (optional): Present only when the server reports a failed apply job. The object is forwarded verbatim from the server and typically includes `jobId`, `code`, `message`, `retryable`, and `failedAt`. It is emitted regardless of `status` so callers can detect server-side failures during `"syncing"` or even after the state has nominally recovered. **Example:** ```sql SELECT cloudsync_network_send_changes(); -- '{"send":{"status":"synced","localVersion":5,"serverVersion":5}}' + +-- With a server-reported failure (e.g. unknown schema hash on the server side): +-- '{"send":{"status":"out-of-sync","localVersion":1,"serverVersion":0,"lastFailure":{"jobId":44961,"code":"internal_error","message":"cloudsync operation failed: Cannot apply the received payload because the schema hash is unknown 4288148391734624266.","retryable":true,"failedAt":"2026-04-15T22:21:09.018606Z"}}}' ``` --- @@ -515,7 +519,7 @@ If a package of new changes is already available for the local site, the server This function is designed to be called periodically to keep the local database in sync. To force an update and wait for changes (with a timeout), use [`cloudsync_network_sync(wait_ms, max_retries)`]. -If the network is misconfigured or the remote server is unreachable, the function returns an error. +If the network is misconfigured, the remote server is unreachable, or the received payload cannot be applied locally (for example because of an unknown schema hash), the function raises a SQL error. Use [`cloudsync_network_sync()`](#cloudsync_network_syncwait_ms-max_retries) if you want receive errors reported as structured JSON alongside the send result instead of as SQL errors. **Parameters:** None. @@ -553,16 +557,18 @@ SELECT cloudsync_network_check_changes(); ```json { - "send": {"status": "synced|syncing|out-of-sync|error", "localVersion": N, "serverVersion": N}, - "receive": {"rows": N, "tables": ["table1", "table2"]} + "send": {"status": "synced|syncing|out-of-sync|error", "localVersion": N, "serverVersion": N, "lastFailure": {...}}, + "receive": {"rows": N, "tables": ["table1", "table2"], "error": "..."} } ``` - `send.status`: The current sync state — `"synced"`, `"syncing"`, `"out-of-sync"`, or `"error"`. - `send.localVersion`: The latest local database version. - `send.serverVersion`: The latest version confirmed by the server. -- `receive.rows`: The number of rows received and applied during the check phase. -- `receive.tables`: An array of table names that received changes. Empty (`[]`) if no changes were applied. +- `send.lastFailure` (optional): Same semantics as in [`cloudsync_network_send_changes()`](#cloudsync_network_send_changes) — forwarded verbatim from the server whenever a failed apply job is reported, regardless of `status`. +- `receive.rows`: The number of rows received and applied during the check phase. `0` when the receive phase failed. +- `receive.tables`: An array of table names that received changes. Empty (`[]`) if no changes were applied or the receive phase failed. +- `receive.error` (optional): Present when the receive phase failed. Contains a human-readable error message (for example `"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."`). Unlike [`cloudsync_network_check_changes()`](#cloudsync_network_check_changes), `cloudsync_network_sync()` does **not** raise a SQL error when the receive phase fails — it returns the structured JSON so the caller can still see that the send phase completed. The retry loop breaks immediately on a receive error, since failures like schema-hash mismatches do not heal across retries. **Example:** @@ -573,6 +579,9 @@ SELECT cloudsync_network_sync(); -- Perform a synchronization cycle with custom retry settings SELECT cloudsync_network_sync(500, 3); + +-- Receive phase failed but send phase completed — the error is surfaced in JSON, not as a SQL error: +-- '{"send":{"status":"synced","localVersion":5,"serverVersion":5},"receive":{"rows":0,"tables":[],"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}' ``` --- diff --git a/CHANGELOG.md b/CHANGELOG.md index efa1bc8..3bd8313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.15] - 2026-04-15 + +### Fixed + +- **Silent receive failures in `cloudsync_network_sync`**: When `cloudsync_payload_apply` failed during the receive phase (for example with an unknown schema hash, invalid checksum, or decompression error), the error was stored only on the internal cloudsync context and never propagated to the SQL caller. `cloudsync_network_check_changes()` returned `NULL` with no error, and `cloudsync_network_sync()` silently dropped the failure. `network_download_changes` now surfaces `cloudsync_payload_apply` failures to the caller. + +### Changed + +- **`cloudsync_network_send_changes()` output** now includes a `send.lastFailure` object whenever the server reports one (raw pass-through of the server's `lastFailure` — `jobId`, `code`, `message`, `retryable`, `failedAt`, …), regardless of whether the computed `send.status` is `synced`, `syncing`, or `out-of-sync`. The field is omitted when the server does not report a failure. +- **`cloudsync_network_sync()` output** now mirrors the same `send.lastFailure` field and, if the receive phase fails (network-level or `cloudsync_payload_apply` error), returns structured JSON with a new `receive.error` string rather than raising a SQL error. The send result is always preserved so callers can tell that their local changes reached the server even when applying incoming changes failed. The receive retry loop now breaks immediately on a receive error (a schema-hash mismatch will not heal across retries). +- **`cloudsync_network_check_changes()`** keeps its error-raising behavior — a receive failure in the standalone check path is still reported as a SQL error, since there is no send result to preserve. + ## [1.0.14] - 2026-04-15 ### Fixed diff --git a/src/cloudsync.h b/src/cloudsync.h index d4f935e..0d86886 100644 --- a/src/cloudsync.h +++ b/src/cloudsync.h @@ -18,7 +18,7 @@ extern "C" { #endif -#define CLOUDSYNC_VERSION "1.0.14" +#define CLOUDSYNC_VERSION "1.0.15" #define CLOUDSYNC_MAX_TABLENAME_LEN 512 #define CLOUDSYNC_VALUE_NOTSET -1 From c56084348ba2cf9de3a77d8fd0009e8fd5acd1a2 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Wed, 15 Apr 2026 23:11:54 -0600 Subject: [PATCH 3/5] fix: treat CLOUDSYNC_NETWORK_OK as normal in receive error capture The err_out path in network_download_changes and cloudsync_network_check_internal was treating all non-BUFFER responses as errors, including CLOUDSYNC_NETWORK_OK which is the normal "no changes ready yet" response. This caused the retry loop in cloudsync_network_sync to break immediately on the first empty check response instead of retrying, resulting in receive.rows always being 0. Now only CLOUDSYNC_NETWORK_ERROR sets err_out; CLOUDSYNC_NETWORK_OK returns rc=0 so the retry loop continues as expected. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/network/network.c | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/network/network.c b/src/network/network.c index 5e22363..abd9f39 100644 --- a/src/network/network.c +++ b/src/network/network.c @@ -443,15 +443,19 @@ int network_download_changes (sqlite3_context *context, const char *download_url else sqlite3_result_error(context, msg, -1); if (pnrows) *pnrows = 0; } - } else { + } else if (result.code == CLOUDSYNC_NETWORK_ERROR) { if (err_out) { const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during receive"; *err_out = cloudsync_string_dup(msg); rc = -1; } else { - rc = network_set_sqlite_result(context, &result); + network_set_sqlite_result(context, &result); + rc = -1; } if (pnrows) *pnrows = 0; + } else { + // CLOUDSYNC_NETWORK_OK — no data, not an error + if (pnrows) *pnrows = 0; } network_result_cleanup(&result); @@ -1123,12 +1127,18 @@ int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync rc = network_download_changes(context, download_url, pnrows, err_out); cloudsync_memory_free(download_url); } else { - if (err_out) { - const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during check"; - *err_out = cloudsync_string_dup(msg); - rc = -1; + if (result.code == CLOUDSYNC_NETWORK_ERROR) { + if (err_out) { + const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during check"; + *err_out = cloudsync_string_dup(msg); + rc = -1; + } else { + network_set_sqlite_result(context, &result); + rc = -1; + } } else { - rc = network_set_sqlite_result(context, &result); + // CLOUDSYNC_NETWORK_OK — no changes ready yet, not an error + rc = 0; } } From 7d2c33e743a7848c19e9de944d90dc4680c5b2f0 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Thu, 16 Apr 2026 11:54:32 -0600 Subject: [PATCH 4/5] fix: limit receive.error to apply failures, endpoint errors raise SQL Refine the error-handling contract: endpoint/network errors (server unreachable, auth failure) always raise a SQL error regardless of which function is called. Only cloudsync_payload_apply failures are surfaced as structured JSON via receive.error. cloudsync_network_check_changes now also returns receive.error in JSON for apply failures (consistent with sync), instead of raising a SQL error for those. Co-Authored-By: Claude Opus 4.6 (1M context) --- API.md | 14 ++++--- CHANGELOG.md | 9 +++-- src/network/network.c | 89 ++++++++++++++++++------------------------- 3 files changed, 51 insertions(+), 61 deletions(-) diff --git a/API.md b/API.md index d2a57ed..9103e04 100644 --- a/API.md +++ b/API.md @@ -519,24 +519,28 @@ If a package of new changes is already available for the local site, the server This function is designed to be called periodically to keep the local database in sync. To force an update and wait for changes (with a timeout), use [`cloudsync_network_sync(wait_ms, max_retries)`]. -If the network is misconfigured, the remote server is unreachable, or the received payload cannot be applied locally (for example because of an unknown schema hash), the function raises a SQL error. Use [`cloudsync_network_sync()`](#cloudsync_network_syncwait_ms-max_retries) if you want receive errors reported as structured JSON alongside the send result instead of as SQL errors. +If the network is misconfigured or the remote server is unreachable, the function raises a SQL error. If the received payload cannot be applied locally (for example because of an unknown schema hash), the error is returned as a `receive.error` field in the JSON response. **Parameters:** None. **Returns:** A JSON string with the receive result: ```json -{"receive": {"rows": N, "tables": ["table1", "table2"]}} +{"receive": {"rows": N, "tables": ["table1", "table2"], "error": "..."}} ``` -- `receive.rows`: The number of rows received and applied to the local database. -- `receive.tables`: An array of table names that received changes. Empty (`[]`) if no changes were applied. +- `receive.rows`: The number of rows received and applied to the local database. `0` when the receive phase failed. +- `receive.tables`: An array of table names that received changes. Empty (`[]`) if no changes were applied or the receive phase failed. +- `receive.error` (optional): Present when `cloudsync_payload_apply` failed. Contains a human-readable error message describing why the received payload could not be applied. **Example:** ```sql SELECT cloudsync_network_check_changes(); -- '{"receive":{"rows":3,"tables":["tasks"]}}' + +-- With an apply error: +-- '{"receive":{"rows":0,"tables":[],"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}' ``` --- @@ -568,7 +572,7 @@ SELECT cloudsync_network_check_changes(); - `send.lastFailure` (optional): Same semantics as in [`cloudsync_network_send_changes()`](#cloudsync_network_send_changes) — forwarded verbatim from the server whenever a failed apply job is reported, regardless of `status`. - `receive.rows`: The number of rows received and applied during the check phase. `0` when the receive phase failed. - `receive.tables`: An array of table names that received changes. Empty (`[]`) if no changes were applied or the receive phase failed. -- `receive.error` (optional): Present when the receive phase failed. Contains a human-readable error message (for example `"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."`). Unlike [`cloudsync_network_check_changes()`](#cloudsync_network_check_changes), `cloudsync_network_sync()` does **not** raise a SQL error when the receive phase fails — it returns the structured JSON so the caller can still see that the send phase completed. The retry loop breaks immediately on a receive error, since failures like schema-hash mismatches do not heal across retries. +- `receive.error` (optional): Present when `cloudsync_payload_apply` failed (for example `"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."`). The send result is always preserved so the caller can tell that local changes reached the server even when applying incoming changes failed. The retry loop breaks immediately on apply errors, since failures like schema-hash mismatches do not heal across retries. Endpoint/network errors during the receive phase raise a SQL error instead. **Example:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd8313..b462a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [1.0.15] - 2026-04-15 +## [1.0.15] - 2026-04-16 ### Fixed -- **Silent receive failures in `cloudsync_network_sync`**: When `cloudsync_payload_apply` failed during the receive phase (for example with an unknown schema hash, invalid checksum, or decompression error), the error was stored only on the internal cloudsync context and never propagated to the SQL caller. `cloudsync_network_check_changes()` returned `NULL` with no error, and `cloudsync_network_sync()` silently dropped the failure. `network_download_changes` now surfaces `cloudsync_payload_apply` failures to the caller. +- **Silent receive failures**: When `cloudsync_payload_apply` failed during the receive phase (for example with an unknown schema hash, invalid checksum, or decompression error), the error was stored only on the internal cloudsync context and never propagated to the SQL caller. Both `cloudsync_network_check_changes()` and `cloudsync_network_sync()` silently returned no result. Apply errors are now surfaced as a `receive.error` field in the JSON response. ### Changed +- **Error handling contract**: endpoint/network errors (server unreachable, auth failure, bad URL) always raise a SQL error. Processing errors (`cloudsync_payload_apply` failures) are returned as structured JSON via `receive.error` or `send.lastFailure`, so callers can inspect and log them without try/catch logic. - **`cloudsync_network_send_changes()` output** now includes a `send.lastFailure` object whenever the server reports one (raw pass-through of the server's `lastFailure` — `jobId`, `code`, `message`, `retryable`, `failedAt`, …), regardless of whether the computed `send.status` is `synced`, `syncing`, or `out-of-sync`. The field is omitted when the server does not report a failure. -- **`cloudsync_network_sync()` output** now mirrors the same `send.lastFailure` field and, if the receive phase fails (network-level or `cloudsync_payload_apply` error), returns structured JSON with a new `receive.error` string rather than raising a SQL error. The send result is always preserved so callers can tell that their local changes reached the server even when applying incoming changes failed. The receive retry loop now breaks immediately on a receive error (a schema-hash mismatch will not heal across retries). -- **`cloudsync_network_check_changes()`** keeps its error-raising behavior — a receive failure in the standalone check path is still reported as a SQL error, since there is no send result to preserve. +- **`cloudsync_network_check_changes()` output** now includes a `receive.error` string when `cloudsync_payload_apply` fails, instead of silently returning NULL. Endpoint/network errors still raise a SQL error. +- **`cloudsync_network_sync()` output** now mirrors the same `send.lastFailure` field and, if the receive phase has a processing error (`cloudsync_payload_apply` failure), returns structured JSON with a `receive.error` string rather than failing silently. The send result is always preserved so callers can tell that their local changes reached the server even when applying incoming changes failed. Endpoint/network errors during the receive phase still raise a SQL error. The receive retry loop breaks immediately on processing errors (a schema-hash mismatch will not heal across retries). ## [1.0.14] - 2026-04-15 diff --git a/src/network/network.c b/src/network/network.c index abd9f39..19f0544 100644 --- a/src/network/network.c +++ b/src/network/network.c @@ -415,19 +415,18 @@ int network_set_sqlite_result (sqlite3_context *context, NETWORK_RESULT *result) return rc; } -// If err_out is non-NULL, any error encountered here is returned via *err_out -// (malloc'd, caller must cloudsync_memory_free) instead of being raised on the -// sqlite3_context. This lets composite callers (cloudsync_network_sync) surface -// receive-side failures as structured JSON rather than SQL errors. +// If err_out is non-NULL, cloudsync_payload_apply failures are returned via +// *err_out (malloc'd, caller must cloudsync_memory_free) instead of being raised +// on the sqlite3_context. This lets composite callers (cloudsync_network_sync) +// surface apply errors as structured JSON. Endpoint/network errors always raise +// a SQL error regardless of err_out. int network_download_changes (sqlite3_context *context, const char *download_url, int *pnrows, char **err_out) { DEBUG_FUNCTION("network_download_changes"); cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); network_data *netdata = (network_data *)cloudsync_auxdata(data); if (!netdata) { - const char *msg = "Unable to retrieve network CloudSync context."; - if (err_out) *err_out = cloudsync_string_dup(msg); - else sqlite3_result_error(context, msg, -1); + sqlite3_result_error(context, "Unable to retrieve network CloudSync context.", -1); return -1; } @@ -444,14 +443,8 @@ int network_download_changes (sqlite3_context *context, const char *download_url if (pnrows) *pnrows = 0; } } else if (result.code == CLOUDSYNC_NETWORK_ERROR) { - if (err_out) { - const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during receive"; - *err_out = cloudsync_string_dup(msg); - rc = -1; - } else { - network_set_sqlite_result(context, &result); - rc = -1; - } + network_set_sqlite_result(context, &result); + rc = -1; if (pnrows) *pnrows = 0; } else { // CLOUDSYNC_NETWORK_OK — no data, not an error @@ -1084,28 +1077,13 @@ void cloudsync_network_send_changes (sqlite3_context *context, int argc, sqlite3 int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync_result *out, char **err_out) { cloudsync_context *data = (cloudsync_context *)sqlite3_user_data(context); network_data *netdata = (network_data *)cloudsync_auxdata(data); - if (!netdata) { - const char *msg = "Unable to retrieve CloudSync network context."; - if (err_out) *err_out = cloudsync_string_dup(msg); - else sqlite3_result_error(context, msg, -1); - return -1; - } + if (!netdata) {sqlite3_result_error(context, "Unable to retrieve CloudSync network context.", -1); return -1;} int64_t db_version = dbutils_settings_get_int64_value(data, CLOUDSYNC_KEY_CHECK_DBVERSION); - if (db_version<0) { - const char *msg = "Unable to retrieve db_version."; - if (err_out) *err_out = cloudsync_string_dup(msg); - else sqlite3_result_error(context, msg, -1); - return -1; - } + if (db_version<0) {sqlite3_result_error(context, "Unable to retrieve db_version.", -1); return -1;} int seq = dbutils_settings_get_int_value(data, CLOUDSYNC_KEY_CHECK_SEQ); - if (seq<0) { - const char *msg = "Unable to retrieve seq."; - if (err_out) *err_out = cloudsync_string_dup(msg); - else sqlite3_result_error(context, msg, -1); - return -1; - } + if (seq<0) {sqlite3_result_error(context, "Unable to retrieve seq.", -1); return -1;} // Capture local db_version before download so we can query cloudsync_changes afterwards int64_t prev_dbv = cloudsync_dbversion(data); @@ -1118,28 +1096,18 @@ int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync if (result.code == CLOUDSYNC_NETWORK_BUFFER) { char *download_url = json_extract_string(result.buffer, result.blen, "url"); if (!download_url) { - const char *msg = "cloudsync_network_check_changes: missing 'url' in check response."; - if (err_out) *err_out = cloudsync_string_dup(msg); - else sqlite3_result_error(context, msg, -1); + sqlite3_result_error(context, "cloudsync_network_check_changes: missing 'url' in check response.", -1); network_result_cleanup(&result); return SQLITE_ERROR; } rc = network_download_changes(context, download_url, pnrows, err_out); cloudsync_memory_free(download_url); + } else if (result.code == CLOUDSYNC_NETWORK_ERROR) { + network_set_sqlite_result(context, &result); + rc = -1; } else { - if (result.code == CLOUDSYNC_NETWORK_ERROR) { - if (err_out) { - const char *msg = (result.buffer && result.buffer[0]) ? result.buffer : "network error during check"; - *err_out = cloudsync_string_dup(msg); - rc = -1; - } else { - network_set_sqlite_result(context, &result); - rc = -1; - } - } else { - // CLOUDSYNC_NETWORK_OK — no changes ready yet, not an error - rc = 0; - } + // CLOUDSYNC_NETWORK_OK — no changes ready yet, not an error + rc = 0; } if (out && pnrows) out->rows_received = *pnrows; @@ -1243,13 +1211,30 @@ void cloudsync_network_check_changes (sqlite3_context *context, int argc, sqlite DEBUG_FUNCTION("cloudsync_network_check_changes"); sync_result sr = {-1, 0, NULL, 0, NULL, NULL}; + char *receive_err = NULL; int nrows = 0; - int rc = cloudsync_network_check_internal(context, &nrows, &sr, NULL); - if (rc != SQLITE_OK) { if (sr.tables_json) cloudsync_memory_free(sr.tables_json); return; } + int rc = cloudsync_network_check_internal(context, &nrows, &sr, &receive_err); + + // Endpoint/network errors already raised a SQL error on the context + if (rc != SQLITE_OK && !receive_err) { if (sr.tables_json) cloudsync_memory_free(sr.tables_json); return; } + + // Apply errors → structured JSON with receive.error + if (receive_err) { + nrows = 0; + if (sr.tables_json) { cloudsync_memory_free(sr.tables_json); sr.tables_json = NULL; } + } const char *tables = sr.tables_json ? sr.tables_json : "[]"; - char *buf = cloudsync_memory_mprintf("{\"receive\":{\"rows\":%d,\"tables\":%s}}", nrows, tables); + char *buf; + if (receive_err) { + char *escaped = json_escape_string(receive_err); + buf = cloudsync_memory_mprintf("{\"receive\":{\"rows\":%d,\"tables\":%s,\"error\":\"%s\"}}", nrows, tables, escaped); + cloudsync_memory_free(escaped); + } else { + buf = cloudsync_memory_mprintf("{\"receive\":{\"rows\":%d,\"tables\":%s}}", nrows, tables); + } sqlite3_result_text(context, buf, -1, cloudsync_memory_free); + if (receive_err) cloudsync_memory_free(receive_err); if (sr.tables_json) cloudsync_memory_free(sr.tables_json); } From e2d77942ef8f61aeaa09ba9e24c9023c98c87826 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Thu, 16 Apr 2026 11:57:08 -0600 Subject: [PATCH 5/5] docs: document error-handling contract in API.md and network.c Co-Authored-By: Claude Opus 4.6 (1M context) --- API.md | 14 ++++++++++++++ src/network/network.c | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/API.md b/API.md index 9103e04..031732d 100644 --- a/API.md +++ b/API.md @@ -481,6 +481,20 @@ SELECT cloudsync_network_set_apikey('your_api_key'); --- +### Error handling + +The sync functions follow a consistent error-handling contract: + +| Error type | Behavior | +|---|---| +| **Endpoint/network errors** (server unreachable, auth failure, bad URL) | SQL error — the function could not execute. | +| **Apply errors** (`cloudsync_payload_apply` failures — unknown schema hash, invalid checksum, decompression error) | Structured JSON — a `receive.error` string field is included in the response. | +| **Server-reported apply job failures** (the server processed the request but its own apply job failed) | Structured JSON — a `send.lastFailure` object is included in the response. | + +This means: if you get JSON back, the server was reachable and the network protocol ran. If you get a SQL error, connectivity or configuration is broken. + +--- + ### `cloudsync_network_send_changes()` **Description:** Sends all unsent local changes to the remote server. diff --git a/src/network/network.c b/src/network/network.c index 19f0544..bd6591b 100644 --- a/src/network/network.c +++ b/src/network/network.c @@ -875,6 +875,16 @@ static char *network_get_affected_tables(sqlite3 *db, int64_t since_db_version) } // MARK: - Sync result +// +// Error-handling contract for send/check/sync functions: +// - Endpoint/network errors (server unreachable, auth failure, bad URL) +// always raise a SQL error via sqlite3_result_error. +// - cloudsync_payload_apply failures (unknown schema hash, invalid checksum, +// decompression error) are returned as structured JSON via receive.error. +// - Server-reported apply job failures are forwarded as send.lastFailure. +// +// Callers that receive JSON can trust that the server was reachable. +// A SQL error means connectivity or configuration is broken. typedef struct { int64_t server_version; // lastOptimisticVersion