Skip to content
Open
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
8 changes: 6 additions & 2 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ name: Release and Update Website Changelog
on:
push:
tags:
# main.yml creates release tags without a "v" prefix (e.g. 1.1.3)
- "*.*.*"
# NOTE: this never matches. main.yml creates release tags without a "v"
# prefix (e.g. 1.1.3), so the changelog is published by running this
# workflow manually. Widening the filter is not enough on its own: the
# called workflow derives the version with ${GITHUB_REF#refs/tags/v} and
# rejects an unprefixed tag. Fixing it needs a change in changelog-action.
- "v*.*.*"
workflow_dispatch:
inputs:
test_version:
Expand Down
20 changes: 11 additions & 9 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,10 +813,11 @@ If the network is misconfigured or the remote server is unreachable, the functio
**Returns:** A JSON string with the receive result:

```json
{"receive": {"rows": N, "tables": ["table1", "table2"], "chunks": C, "bytes": B, "complete": true, "error": "...", "lastFailure": {...}}}
{"receive": {"rows": N, "denied": D, "tables": ["table1", "table2"], "chunks": C, "bytes": B, "complete": true, "error": "...", "lastFailure": {...}}}
```

- `receive.rows`: The total number of rows received and applied to the local database, summed across all chunks drained this call. `0` when the receive phase failed, when nothing was available, or when only intermediate fragments were staged without completing a value.
- `receive.denied`: The number of entries a row-level security policy rejected, summed across all chunks drained this call. Denied entries are skipped and the receive cursor still advances past them: the rejection is permanent, so holding the cursor back would re-deliver the same entries on every call. They are not counted in `receive.rows`, so a non-zero `denied` alongside a `rows` of `0` means nothing was written — the shape of an apply connection whose session identity is not set.
- `receive.tables`: An array of table names that received changes (the union across all drained chunks). Empty (`[]`) if no changes were applied or the receive phase failed.
- `receive.chunks`: The number of payload chunks applied by this call. `0` when nothing was ready, `1` for a single monolithic/inline page, and `N` for a drained `N`-chunk stream (bounded by `max_chunks` if given).
- `receive.bytes`: The total serialized payload bytes received this call (uncompressed cloudsync payload size, summed across chunks; transport-independent, not the compressed wire size). Useful for byte-budgeted draining together with `max_chunks`.
Expand All @@ -828,16 +829,16 @@ If the network is misconfigured or the remote server is unreachable, the functio

```sql
SELECT cloudsync_network_receive_changes();
-- '{"receive":{"rows":3,"tables":["tasks"],"chunks":1,"bytes":820,"complete":true}}'
-- '{"receive":{"rows":3,"denied":0,"tables":["tasks"],"chunks":1,"bytes":820,"complete":true}}'

-- Capped drain with more pending (call again to continue):
-- '{"receive":{"rows":40,"tables":["docs"],"chunks":5,"bytes":1310720,"complete":false}}'
-- '{"receive":{"rows":40,"denied":0,"tables":["docs"],"chunks":5,"bytes":1310720,"complete":false}}'

-- With a client-side apply error:
-- '{"receive":{"rows":0,"tables":[],"chunks":0,"bytes":0,"complete":true,"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}'
-- '{"receive":{"rows":0,"denied":0,"tables":[],"chunks":0,"bytes":0,"complete":true,"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}'

-- With a server-reported check-job failure:
-- '{"receive":{"rows":0,"tables":[],"chunks":0,"bytes":0,"complete":true,"lastFailure":{"jobId":456,"dbVersion":15,"seq":1,"code":"tenant_unreachable","stage":"encode_changes","message":"tenant check failed","retryable":true,"failedAt":"2026-04-24T10:22:00Z"}}}'
-- '{"receive":{"rows":0,"denied":0,"tables":[],"chunks":0,"bytes":0,"complete":true,"lastFailure":{"jobId":456,"dbVersion":15,"seq":1,"code":"tenant_unreachable","stage":"encode_changes","message":"tenant check failed","retryable":true,"failedAt":"2026-04-24T10:22:00Z"}}}'
```

---
Expand Down Expand Up @@ -867,7 +868,7 @@ When the server delivers changes as a stream of chunks, this function drains the
```json
{
"send": {"status": "synced|syncing|out-of-sync|error", "localVersion": N, "serverVersion": N, "chunks": C, "bytes": B, "lastFailure": {...}},
"receive": {"rows": N, "tables": ["table1", "table2"], "chunks": C, "bytes": B, "complete": true, "error": "...", "lastFailure": {...}}
"receive": {"rows": N, "denied": D, "tables": ["table1", "table2"], "chunks": C, "bytes": B, "complete": true, "error": "...", "lastFailure": {...}}
}
```

Expand All @@ -877,6 +878,7 @@ When the server delivers changes as a stream of chunks, this function drains the
- `send.chunks` / `send.bytes`: Number of payload chunks sent and total serialized payload bytes sent during the send phase. Same semantics as in [`cloudsync_network_send_changes()`](#cloudsync_network_send_changes).
- `send.lastFailure` (optional): Same semantics as in [`cloudsync_network_send_changes()`](#cloudsync_network_send_changes) — forwarded verbatim from the server's `failures.apply` whenever a failed apply job is reported, regardless of `status`.
- `receive.rows`: The **total** number of rows received and applied during the receive phase, summed across **all** chunks drained in this call. `0` when the receive phase failed.
- `receive.denied`: The **total** number of entries rejected by a row-level security policy across **all** chunks drained in this call. Skipped rather than retried, and not counted in `receive.rows` — see [Receive Changes](#receive-changes).
- `receive.tables`: An array of table names that received changes (the union across all drained chunks). Empty (`[]`) if no changes were applied or the receive phase failed.
- `receive.chunks`: The number of payload chunks applied in this call. `0` when nothing was ready, `1` for a single monolithic/inline page, and `N` for a fully drained `N`-chunk stream. `cloudsync_network_sync()` always drains the whole stream (it does not cap chunks).
- `receive.bytes`: The total serialized payload bytes received this call (uncompressed cloudsync payload size, summed across chunks; not the compressed wire size). Same semantics as in [`cloudsync_network_receive_changes()`](#cloudsync_network_receive_changesmax_chunks).
Expand All @@ -889,15 +891,15 @@ When the server delivers changes as a stream of chunks, this function drains the
```sql
-- Perform a single synchronization cycle
SELECT cloudsync_network_sync();
-- '{"send":{"status":"synced","localVersion":5,"serverVersion":5,"chunks":1,"bytes":2048},"receive":{"rows":3,"tables":["tasks"],"chunks":1,"bytes":820,"complete":true}}'
-- '{"send":{"status":"synced","localVersion":5,"serverVersion":5,"chunks":1,"bytes":2048},"receive":{"rows":3,"denied":0,"tables":["tasks"],"chunks":1,"bytes":820,"complete":true}}'

-- Perform a synchronization cycle with custom retry settings
SELECT cloudsync_network_sync(500, 3);
-- A large download drained as a multi-chunk stream in a single call:
-- '{"send":{"status":"synced","localVersion":42,"serverVersion":42,"chunks":0,"bytes":0},"receive":{"rows":1200,"tables":["docs"],"chunks":7,"bytes":1835008,"complete":true}}'
-- '{"send":{"status":"synced","localVersion":42,"serverVersion":42,"chunks":0,"bytes":0},"receive":{"rows":1200,"denied":0,"tables":["docs"],"chunks":7,"bytes":1835008,"complete":true}}'

-- 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,"chunks":1,"bytes":512},"receive":{"rows":0,"tables":[],"chunks":0,"bytes":0,"complete":false,"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}'
-- '{"send":{"status":"synced","localVersion":5,"serverVersion":5,"chunks":1,"bytes":512},"receive":{"rows":0,"denied":0,"tables":[],"chunks":0,"bytes":0,"complete":false,"error":"Cannot apply the received payload because the schema hash is unknown 7218827471400075525."}}'
```

---
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ 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.1.4] - 2026-09-11

### Added

- **Rows rejected by a row-level security policy are now reported** as `receive.denied` in the JSON returned by `cloudsync_network_receive_changes()` and `cloudsync_network_sync()`, counted across every chunk of a receive. A denial is a permanent, expected outcome — those rows are not this site's to hold — so they are skipped and the receive cursor still advances past them; without a count, discarding them would be invisible. They are not counted in `receive.rows`, which reports what was actually written. This does not yet extend to a value large enough to be sent in fragments: a denial there is still reported as a receive error and the cursor does not advance. A non-zero `denied` with a zero `rows` is the shape of an apply connection whose session identity (`auth.uid()` / `app.current_user_id`) is not set.
- **Network requests now have deadlines**, where previously a stalled server left a sync call waiting indefinitely. 30 seconds to connect, on every request. API calls are then capped at 300 seconds of elapsed time. Artifact transfers are bounded on progress instead — they abort after 60 seconds below 1 KB/s — because a large payload on a slow link would otherwise be killed mid-flight by an elapsed-time cap, and because a genuine stall is detected sooner this way. A 1-hour absolute backstop still bounds an artifact transfer that trickles just fast enough to stay alive. Override at build time with `CLOUDSYNC_CONNECT_TIMEOUT_SECONDS`, `CLOUDSYNC_REQUEST_TIMEOUT_SECONDS`, `CLOUDSYNC_ARTIFACT_LOW_SPEED_LIMIT`, `CLOUDSYNC_ARTIFACT_LOW_SPEED_TIME` and `CLOUDSYNC_ARTIFACT_TIMEOUT_SECONDS`.
- **Decompressed payloads are capped at 256 MiB before allocation**, overridable with `CLOUDSYNC_MAX_PAYLOAD_EXPANDED_SIZE` (LZ4's own `INT_MAX` bound still applies). Default chunk sizes are far below this limit; an oversized legacy monolithic payload must be rechunked or the limit raised explicitly.

### Fixed

- **A failed payload write now reports the error and leaves the receive cursor where it was.** An error on one row could previously be overwritten by a later successful row, so `cloudsync_payload_apply` could report success after dropping changes and still advance the checkpoint — losing them silently. SQLite keeps its existing per-group partial-application behaviour.
- **Primary-key doubles keep their deployed little-endian IEEE754 byte order on every architecture.** The previous code combined host conversion with manual big-endian serialization; the historical format is now explicit. Integer keys are unchanged, and no migration is needed for little-endian deployments.
- **PostgreSQL no longer frees a tuple table belonging to another open cursor.** A block write that failed while a second SPI cursor was active could release rows still in use; tuple tables are now owned per statement.
- **Block-level LWW text writes roll back cleanly when a block write fails**, instead of leaving the row partially written.
- **Block-column failures now say which table and column failed**, instead of aborting the statement with a blank message. Reading the row back is part of writing a block column, so a row the session cannot `SELECT` — a row-level security policy narrower for reads than for writes, say — is reported with that cause rather than as an empty "not an error".
- **64-bit clock values above `UINT32_MAX` are handled correctly on incoming changes** — column and database versions, causal length, and sequence.
- **The Node package rejects `ia32` on every operating system** rather than selecting an incompatible binary. `x64` and `arm64-musl` selection is unchanged.

## [1.1.3] - 2026-09-11

### Added
Expand Down
23 changes: 18 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,9 @@ TEST_TARGET = $(patsubst %.c,$(DIST_DIR)/%$(EXE), $(notdir $(TEST_SRC)))
# tested directly on in-memory buffers. NT_LDFLAGS reuses the platform LDFLAGS
# (which carries -lcurl) minus the shared-library-only flags (-shared on Linux,
# -dynamiclib on macOS) so it links as an executable, plus the test link libs.
# -undefined dynamic_lookup is kept: the test never opens a connection, so curl's
# transport symbols are linked but never invoked.
# The deadline regression uses a loopback socket; no external service is needed.
BUILD_NETTEST = build/nettest
NT_CFLAGS = $(filter-out -DCLOUDSYNC_OMIT_NETWORK,$(T_CFLAGS))
NT_CFLAGS = $(filter-out -DCLOUDSYNC_OMIT_NETWORK,$(T_CFLAGS)) -DCLOUDSYNC_REQUEST_TIMEOUT_SECONDS=1L -DCLOUDSYNC_CONNECT_TIMEOUT_SECONDS=1L -DCLOUDSYNC_ARTIFACT_LOW_SPEED_TIME=1L -DCLOUDSYNC_ARTIFACT_TIMEOUT_SECONDS=30L
NT_LDFLAGS = $(filter-out -shared -dynamiclib -headerpad_max_install_names,$(LDFLAGS)) $(T_LDFLAGS)
NT_SRC = $(SRC_FILES) $(SQLITE_DIR)/sqlite3.c $(TEST_DIR)/network_unit.c
NT_OBJ = $(patsubst %.c,$(BUILD_NETTEST)/%.o,$(notdir $(NT_SRC)))
Expand Down Expand Up @@ -298,8 +297,22 @@ ifneq ($(COVERAGE),false)
endif

# Run only unit tests
unittest: $(TARGET) $(DIST_DIR)/unit$(EXE)
unittest: $(TARGET) $(DIST_DIR)/unit$(EXE) $(DIST_DIR)/review_regressions$(EXE)
@./$(DIST_DIR)/unit$(EXE)
@./$(DIST_DIR)/review_regressions$(EXE)

# Force pk.c's endian-conversion branch while preserving the real host ABI.
# This catches double conversion regressions even on a little-endian CI host.
$(BUILD_TEST)/pk_forced_big_endian.o: $(SRC_DIR)/pk.c $(SRC_DIR)/cloudsync_endian.h
@mkdir -p $(BUILD_TEST)
$(CC) $(T_CFLAGS) -U__BYTE_ORDER__ -D__BYTE_ORDER__=__ORDER_BIG_ENDIAN__ -c $< -o $@

$(DIST_DIR)/review_regressions_big_endian$(EXE): $(TEST_OBJ) $(BUILD_TEST)/pk_forced_big_endian.o
$(CC) $(filter-out $(BUILD_TEST)/pk.o $(patsubst %.c,$(BUILD_TEST)/%.o,$(notdir $(TEST_SRC))),$(TEST_OBJ)) $(BUILD_TEST)/review_regressions.o $(BUILD_TEST)/pk_forced_big_endian.o -o $@ $(T_LDFLAGS)

.PHONY: endian-unittest
endian-unittest: $(DIST_DIR)/review_regressions_big_endian$(EXE)
@./$(DIST_DIR)/review_regressions_big_endian$(EXE)

# Network-enabled unit test binary. Link it via a file rule (like dist/unit), not in
# the run recipe below: on Android `make test` runs binaries on the emulator from a
Expand All @@ -308,7 +321,7 @@ unittest: $(TARGET) $(DIST_DIR)/unit$(EXE)
$(DIST_DIR)/network_unit$(EXE): $(CURL_LIB) $(NT_OBJ)
$(CC) $(NT_OBJ) -o $@ $(NT_LDFLAGS)

# Run the network-layer unit tests (networking compiled in, no server)
# Run the network-layer unit tests (networking compiled in, loopback only)
network-unittest: $(DIST_DIR)/network_unit$(EXE)
@./$(DIST_DIR)/network_unit$(EXE)

Expand Down
74 changes: 74 additions & 0 deletions docs/internal/audit-regressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Repository audit fixes

The previously reported `MAX_PARAMS` issue is outside this change.

## Compatibility and limits

- PK doubles retain their deployed little-endian IEEE754 bytes. The old code
combined host conversion with manual big-endian serialization; unconditional
byte swapping now makes the historical format explicit on every architecture.
Integers keep their existing encoding. No migration is needed for supported
little-endian deployments.
- Decompressed payloads are limited to 256 MiB before allocation. Builds may
override `CLOUDSYNC_MAX_PAYLOAD_EXPANDED_SIZE`; LZ4's `INT_MAX` bound still
applies. Existing default chunk sizes are below this limit. Oversized legacy
monolithic payloads must be rechunked or used with an explicitly raised limit.
- Curl requests now have a 30-second connection deadline and a 300-second total
deadline, including reused handles. Build overrides are
`CLOUDSYNC_CONNECT_TIMEOUT_SECONDS` and `CLOUDSYNC_REQUEST_TIMEOUT_SECONDS`.
- Failed payload writes report an error and do not advance the receive cursor.
PostgreSQL's explicit RLS WITH CHECK rejection remains a skippable policy
outcome, distinct from generic SQL/permission errors; the call still reports
processed rows, but does not advance the cursor when a policy denied rows.
SQLite retains its existing per-group partial-application behavior.
- Test database files live in a private per-run temporary directory, not HOME.
The harness deletes only its own flat test files at shutdown.

## Regression coverage

| Area | Focused coverage |
| --- | --- |
| 64-bit clocks | Incoming column/database versions, causal length and sequence above UINT32_MAX |
| Double encoding | Golden bytes, decoding a deployed fixture, negative-value roundtrip; forced big-endian conversion build |
| Virtual-table planner | Unusable/unsupported constraints before an accepted constraint; no accepted constraints |
| Payload failures | First, middle and final PK errors; checkpoint unchanged; allocation limits |
| Metadata refill | Trigger rejects insertion of a missing column clock |
| Block LWW | Insert/update rollback on block write failure; allocation failure at each split/list/diff allocation |
| PostgreSQL ownership | Block failure while another SPI cursor is active; no invalid tuple-table cleanup |
| JSON | Root-only member lookup, string values resembling keys, nested keys, Unicode and invalid surrogates |
| Curl | Stalled loopback HTTP server, unpooled handle and pooled handle before/after reset |
| Node | ia32 rejection on all OS families and preservation of x64/arm64-musl selection |
| Fractional indexing | 4096-byte common prefix plus the existing module suite |
| Test harness | Temporary directory cleanup, memory accounting and sanitizer-safe RowID generation |

Run `make unittest`, `make endian-unittest`, `make network-unittest`, and
`make -C modules/fractional-indexing/test run`. The network test needs permission
to bind a loopback socket; it does not contact an external service. Run Node
checks from `packages/node` with `npm test -- --run`, `npm run typecheck`, and
`npm run build`.

PostgreSQL's `test/postgresql/full_test.sql` includes the focused audit cases in
`57_audit_regressions.sql`. Run it only against a disposable PostgreSQL instance:
the existing suite creates and drops its test databases.

The fractional-indexing changes and its new test are inside a Git submodule;
they must be recorded there before updating the parent repository's submodule
pointer when preparing a commit.

## Validation performed (2026-09-11)

- macOS arm64: all 150 existing unit checks and the new audit regressions passed.
- The same SQLite suites passed AddressSanitizer and UndefinedBehaviorSanitizer
with `UBSAN_OPTIONS=halt_on_error=1`, including zero outstanding SQLite memory.
- The forced big-endian conversion regression build passed.
- All 7 network tests and all 340 fractional-indexing module tests passed.
- PostgreSQL 17, rebuilt in an isolated Linux container: 479 reported checks
passed across 57 test groups, with zero failures and no SPI cleanup warnings.
- Node: 14 tests, TypeScript checking, and CJS/ESM/declaration builds passed.
- PostgreSQL migration compatibility check and Git whitespace checks passed.

No live cloud-service integration was run. Windows, Android and WebAssembly
runtime suites were not executed. The x86_64 runtime attempt was unavailable on
this host (`Bad CPU type in executable`, Rosetta not available). The endian
test exercises conversion logic; it is not a substitute for real big-endian
hardware testing.
2 changes: 1 addition & 1 deletion modules/fractional-indexing
Loading
Loading