diff --git a/API.md b/API.md index dd47852..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. @@ -490,18 +504,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,24 +533,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 or the remote server is unreachable, the function returns an error. +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."}}' ``` --- @@ -553,16 +575,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 `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:** @@ -573,6 +597,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..b462a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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-16 + +### Fixed + +- **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_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 ### 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 diff --git a/src/network/network.c b/src/network/network.c index 6660005..bd6591b 100644 --- a/src/network/network.c +++ b/src/network/network.c @@ -415,27 +415,43 @@ 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, 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) { sqlite3_result_error(context, "Unable to retrieve network CloudSync context.", -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 if (result.code == CLOUDSYNC_NETWORK_ERROR) { + network_set_sqlite_result(context, &result); + rc = -1; + if (pnrows) *pnrows = 0; } else { - rc = network_set_sqlite_result(context, &result); + // CLOUDSYNC_NETWORK_OK — no data, not an error if (pnrows) *pnrows = 0; } network_result_cleanup(&result); - + return rc; } @@ -557,6 +573,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 @@ -797,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 @@ -804,6 +892,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 +1020,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 +1051,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,18 +1063,28 @@ 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;} @@ -1006,10 +1110,14 @@ int cloudsync_network_check_internal(sqlite3_context *context, int *pnrows, sync 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 if (result.code == CLOUDSYNC_NETWORK_ERROR) { + 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; } if (out && pnrows) out->rows_received = *pnrows; @@ -1025,28 +1133,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,14 +1220,31 @@ 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}; + char *receive_err = NULL; int nrows = 0; - int rc = cloudsync_network_check_internal(context, &nrows, &sr); - 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); }