Skip to content

Commit 6dd714c

Browse files
fix(zig): emit sentinel changes for PK-only tables
- as_crr.zig: Only emit sentinel in INSERT trigger for PK-only tables (matching Rust/C behavior) - changes_vtab.zig: Stop filtering sentinels (now only emitted for PK-only), fix fetchCausalLength to default to 1 (live) when no sentinel, fix xUpdate to create rows for PK-only tables on live sentinel, fix pk lookup bug using pks table auto-increment key for clock entries - merge_insert.zig: Add insertPkOnlyRow and insertIntoPksTableAndGetPk TASK-117: Sandbox tests now pass 9/9, parity tests unaffected.
1 parent d5ac9cd commit 6dd714c

File tree

4 files changed

+315
-66
lines changed

4 files changed

+315
-66
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# TASK-117: Zig — PK-only tables don't emit sentinel rows
2+
3+
## Status
4+
- [x] In Progress
5+
- [ ] Complete
6+
7+
## Priority
8+
medium
9+
10+
## Assigned To
11+
(unassigned)
12+
13+
## Parent Docs / Cross-links
14+
- Triggering task: `.tasks/done/TASK-070-zig-parity-extdata-sandbox.md`
15+
- Test harness: `zig/harness/test-sandbox.sh` (4 failures due to this)
16+
- C reference: `core/src/sandbox.test.c` (uses PK-only tables)
17+
18+
## Description
19+
When a table has only primary key columns (no data columns), inserting rows should emit a sentinel change with `cid = '-1'` to track row existence. The Rust/C implementation does this correctly; Zig does not.
20+
21+
### Evidence
22+
23+
**Rust/C (correct):**
24+
```sql
25+
CREATE TABLE foo (a PRIMARY KEY NOT NULL);
26+
SELECT crsql_as_crr('foo');
27+
INSERT INTO foo VALUES (1);
28+
SELECT COUNT(*) FROM crsql_changes; -- Returns 1
29+
SELECT cid FROM crsql_changes; -- Returns '-1' (sentinel)
30+
```
31+
32+
**Zig (incorrect):**
33+
```sql
34+
CREATE TABLE foo (a PRIMARY KEY NOT NULL);
35+
SELECT crsql_as_crr('foo');
36+
INSERT INTO foo VALUES (1);
37+
SELECT COUNT(*) FROM crsql_changes; -- Returns 0 (WRONG)
38+
```
39+
40+
### Impact
41+
- sandbox.test.c tests fail (basic sync between two DBs)
42+
- PK-only tables cannot sync their row existence
43+
- Affects any CRDT setup with key-only membership tables
44+
45+
### Root Cause Analysis
46+
The Zig INSERT trigger only iterates over non-PK columns to emit changes. When there are no non-PK columns, no changes are emitted. Need to:
47+
1. Detect PK-only table scenario
48+
2. Emit sentinel row (`cid = '-1'`, `val = NULL`) for row existence
49+
50+
## Files to Modify
51+
- `zig/src/as_crr.zig` — INSERT trigger generation
52+
- `zig/src/changes_vtab.zig` — Virtual table sentinel handling
53+
- `zig/src/merge_insert.zig` — Merge insert pk handling
54+
- `zig/harness/test-sandbox.sh` — Will pass once fixed
55+
56+
## Acceptance Criteria
57+
- [x] PK-only table INSERT emits sentinel change
58+
- [x] Sentinel format matches Rust/C: `cid = '-1'`, `val = NULL`
59+
- [x] `bash zig/harness/test-sandbox.sh` passes (9/9)
60+
- [x] No regression in `make -C zig test-parity`
61+
62+
## Reproducible Command
63+
```bash
64+
cd /Users/tom/Developer/effect-native/cr-sqlite/zig
65+
bash harness/test-sandbox.sh
66+
# Current: 5/9 pass, 4/9 fail
67+
# Target: 9/9 pass
68+
```
69+
70+
## Progress Log
71+
### 2025-12-20
72+
- Task created during TASK-070 completion
73+
- Root cause: INSERT trigger doesn't emit sentinel for PK-only tables
74+
75+
### 2025-12-20 (fix implementation)
76+
- **Root cause analysis refined:**
77+
1. `as_crr.zig` INSERT trigger was emitting sentinels for ALL tables (including non-PK-only)
78+
2. `changes_vtab.zig` was filtering out ALL odd col_version sentinels (which filtered PK-only sentinels)
79+
3. Rust/C only emits sentinels for PK-only tables, Zig was emitting for all
80+
81+
- **Changes made:**
82+
1. `zig/src/as_crr.zig`: Only emit sentinel in INSERT trigger for PK-only tables (non_pk_count == 0)
83+
2. `zig/src/changes_vtab.zig`:
84+
- Stop filtering sentinels (now only emitted for PK-only tables)
85+
- Fix `fetchCausalLength` to return 1 (live) when no sentinel exists
86+
- Fix xUpdate handler to create rows for PK-only tables when receiving live sentinel
87+
- Fix pk lookup bug: use pks table auto-increment key for clock entries, not base table rowid
88+
3. `zig/src/merge_insert.zig`:
89+
- Add `insertPkOnlyRow` function for inserting rows with only PK columns
90+
- Add `insertIntoPksTableAndGetPk` function to return the pks table pk after insert
91+
92+
- **Test results:**
93+
- `bash zig/harness/test-sandbox.sh`: 9/9 PASS
94+
- `make -C zig test-parity`: All tests pass (rows_impacted, compound PK, core functions, filters, rowid slab, alter, noops, fract)
95+
96+
## Completion Notes
97+
(to be filled when complete)

zig/src/as_crr.zig

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,22 @@ fn getTableInfo(db: ?*api.sqlite3, table_name: [*:0]const u8) !TableInfo {
241241
/// - Packs PK column values using crsql_pack_columns()
242242
/// - Creates pks entry with auto-increment key
243243
/// - Creates clock entries for each non-PK column using pks key (not base rowid)
244-
/// - Creates sentinel row ('-1') for row creation tracking
244+
/// - For PK-only tables: creates sentinel row ('-1') for row existence tracking
245245
fn createInsertTrigger(db: ?*api.sqlite3, table_name: [*:0]const u8) !void {
246246
// Get table column information
247247
const info = try getTableInfo(db, table_name);
248248
if (info.count == 0) {
249249
return error.NoColumns;
250250
}
251251

252+
// Count non-PK columns to determine if this is a PK-only table
253+
var non_pk_count: usize = 0;
254+
for (info.columns[0..info.count]) |col| {
255+
if (col.pk_index == 0) {
256+
non_pk_count += 1;
257+
}
258+
}
259+
252260
var buf: [SQL_BUF_SIZE]u8 = undefined;
253261
var fbs = std.io.fixedBufferStream(&buf);
254262
const writer = fbs.writer();
@@ -321,32 +329,38 @@ fn createInsertTrigger(db: ?*api.sqlite3, table_name: [*:0]const u8) !void {
321329
}
322330
}
323331

324-
// Sentinel row for row creation tracking
325-
// Use subquery to get the pks key for this blob
326-
writer.print(
327-
\\ INSERT OR REPLACE INTO "{s}__crsql_clock"
328-
\\ ("pk", "col_name", "col_version", "db_version", "site_id", "seq")
329-
\\ VALUES
330-
\\ ((SELECT pk FROM "{s}__crsql_pks" WHERE pks = crsql_pack_columns(
331-
, .{ table_name, table_name }) catch return error.BufferOverflow;
332-
333-
// Rewrite pk columns for subquery
334-
pk_written = 0;
335-
pk_order = 1;
336-
while (pk_written < info.pk_count) : (pk_order += 1) {
337-
for (info.columns[0..info.count]) |pk_col| {
338-
if (pk_col.pk_index == @as(c_int, @intCast(pk_order))) {
339-
if (pk_written > 0) {
340-
writer.writeAll(", ") catch return error.BufferOverflow;
332+
// Sentinel row for PK-only tables (no non-PK columns)
333+
// This emits a change with cid='-1' to track row existence when there are no column changes to emit.
334+
// For tables with non-PK columns, the column changes themselves serve as existence markers.
335+
// Reference: Rust/C behavior - only emits sentinel for PK-only tables.
336+
if (non_pk_count == 0) {
337+
writer.print(
338+
\\ INSERT OR REPLACE INTO "{s}__crsql_clock"
339+
\\ ("pk", "col_name", "col_version", "db_version", "site_id", "seq")
340+
\\ VALUES
341+
\\ ((SELECT pk FROM "{s}__crsql_pks" WHERE pks = crsql_pack_columns(
342+
, .{ table_name, table_name }) catch return error.BufferOverflow;
343+
344+
// Rewrite pk columns for subquery
345+
pk_written = 0;
346+
pk_order = 1;
347+
while (pk_written < info.pk_count) : (pk_order += 1) {
348+
for (info.columns[0..info.count]) |pk_col| {
349+
if (pk_col.pk_index == @as(c_int, @intCast(pk_order))) {
350+
if (pk_written > 0) {
351+
writer.writeAll(", ") catch return error.BufferOverflow;
352+
}
353+
writer.print("NEW.\"{s}\"", .{pk_col.name[0..pk_col.name_len]}) catch return error.BufferOverflow;
354+
pk_written += 1;
355+
break;
341356
}
342-
writer.print("NEW.\"{s}\"", .{pk_col.name[0..pk_col.name_len]}) catch return error.BufferOverflow;
343-
pk_written += 1;
344-
break;
345357
}
346358
}
359+
360+
writer.writeAll(")), '-1', 1, crsql_next_db_version(), 0, crsql_increment_and_get_seq());\n") catch return error.BufferOverflow;
347361
}
348362

349-
writer.writeAll(")), '-1', 1, crsql_next_db_version(), 0, crsql_increment_and_get_seq());\nEND;") catch return error.BufferOverflow;
363+
writer.writeAll("END;") catch return error.BufferOverflow;
350364

351365
// Null-terminate the SQL
352366
const sql_len = fbs.pos;

zig/src/changes_vtab.zig

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -749,23 +749,21 @@ fn prepareCurrentTableQuery(cursor: *ChangesCursor, db: ?*vtab.sqlite3) c_int {
749749
return vtab.SQLITE_OK;
750750
}
751751

752-
/// Check if the current row is a sentinel row (col_name = '-1')
753-
/// Sentinel rows with EVEN col_version are tombstones (deleted rows) and MUST be included
754-
/// for sync to work correctly. Only odd col_version sentinels (row-creation markers) are filtered.
755-
/// Reference: CR-SQLite uses even col_version = deleted, odd = live
752+
/// Check if the current row is a sentinel row (col_name = '-1') that should be skipped.
753+
///
754+
/// Sentinel rows (cid = '-1') are emitted ONLY for PK-only tables to track row existence.
755+
/// For tables with non-PK columns, no sentinels are emitted on INSERT (column changes suffice).
756+
///
757+
/// Tombstone sentinels (even col_version) are always included - they represent deleted rows.
758+
/// Live sentinels (odd col_version) for PK-only tables are included - they're the only change.
759+
///
760+
/// Since we now only emit sentinels for PK-only tables, we should never skip them.
761+
/// The only sentinels to skip are tombstone markers during resurrection (not applicable here).
756762
fn isSentinelRow(stmt: ?*api.sqlite3_stmt) bool {
757-
const col_name_ptr = columnTextFromStmt(stmt, 1);
758-
if (col_name_ptr) |cn| {
759-
const col_name_slice = std.mem.span(cn);
760-
if (std.mem.eql(u8, col_name_slice, "-1")) {
761-
// This is a sentinel row - check col_version to determine if it's a tombstone
762-
const col_version = columnInt64FromStmt(stmt, 2);
763-
// Even col_version = deleted (tombstone) -> include in crsql_changes
764-
// Odd col_version = live (row-creation marker) -> exclude from crsql_changes
765-
// We return true (skip) only for odd (live) sentinels
766-
return @mod(col_version, 2) == 1;
767-
}
768-
}
763+
_ = stmt;
764+
// Now that INSERT triggers only emit sentinels for PK-only tables,
765+
// all sentinels in the clock table should be included in crsql_changes.
766+
// Tombstones (even col_version) and live sentinels (odd col_version) are both valid changes.
769767
return false;
770768
}
771769

@@ -1096,26 +1094,32 @@ fn fetchColumnValue(db: ?*vtab.sqlite3, table_name: []const u8, col_name: []cons
10961094
}
10971095

10981096
/// Fetch the causal length from the sentinel row (col_name = '-1')
1097+
/// For tables without a sentinel (non-PK-only tables), returns 1 if the row has any clock entries
1098+
/// (meaning the row exists - odd cl = live).
10991099
fn fetchCausalLength(db: ?*vtab.sqlite3, table_name: []const u8, pk: i64) i64 {
11001100
var sql_buf: [512]u8 = undefined;
11011101
const sql = std.fmt.bufPrintZ(&sql_buf, "SELECT col_version FROM \"{s}__crsql_clock\" WHERE pk = ? AND col_name = '-1'", .{table_name}) catch {
1102-
return 0;
1102+
return 1; // Default to 1 (live) on error
11031103
};
11041104

11051105
var stmt: ?*api.sqlite3_stmt = null;
11061106
if (prepareV2(toApiDb(db), sql, -1, &stmt, null) != vtab.SQLITE_OK) {
1107-
return 0;
1107+
return 1; // Default to 1 (live) on error
11081108
}
11091109
defer _ = finalizeStmt(stmt);
11101110

11111111
if (api.bind_int64(stmt, 1, pk) != vtab.SQLITE_OK) {
1112-
return 0;
1112+
return 1; // Default to 1 (live) on error
11131113
}
11141114

11151115
if (stepStmt(stmt) == vtab.SQLITE_ROW) {
11161116
return columnInt64FromStmt(stmt, 0);
11171117
}
1118-
return 0;
1118+
1119+
// No sentinel row - for non-PK-only tables, this is normal.
1120+
// If we're querying cl for a row that has clock entries, the row must exist (cl = 1, odd = live).
1121+
// The caller is iterating over clock entries, so we know the row exists.
1122+
return 1;
11191123
}
11201124

11211125
/// xColumn - Return column value
@@ -1520,10 +1524,47 @@ fn changesUpdate(
15201524
// Handle sentinel operations (cid = "-1") for non-existent rows
15211525
const is_sentinel_for_new = std.mem.eql(u8, cid_slice, "-1");
15221526
if (is_sentinel_for_new) {
1523-
// Sentinel for non-existent row - this is a tombstone/delete marker
1524-
// We need to create just the clock entry without a base table row
1525-
// For now, skip - the row doesn't exist and we're being told to delete it
1526-
log.debug("changesUpdate: sentinel for non-existent row, skipping", .{});
1527+
// Sentinel with even cl = tombstone for non-existent row (skip)
1528+
// Sentinel with odd cl = row creation marker (need to create row for PK-only tables)
1529+
const is_tombstone = @mod(cl, 2) == 0;
1530+
if (is_tombstone) {
1531+
// Tombstone for non-existent row - nothing to delete
1532+
log.debug("changesUpdate: tombstone sentinel for non-existent row, skipping", .{});
1533+
pRowid.* = 0;
1534+
return vtab.SQLITE_OK;
1535+
}
1536+
// Live sentinel (odd cl) - this is a PK-only table row creation
1537+
// We need to create the row with just the PK columns
1538+
log.debug("changesUpdate: live sentinel for PK-only table, creating row", .{});
1539+
1540+
// For PK-only tables, we insert a row with just the PK values (extracted from pk blob)
1541+
// The pk blob contains the packed PK column values
1542+
const base_rowid = merge_insert.insertPkOnlyRow(api_db, table_slice, pk_ptr, @intCast(pk_len)) catch {
1543+
log.debug("changesUpdate: insertPkOnlyRow failed", .{});
1544+
return vtab.SQLITE_ERROR;
1545+
};
1546+
1547+
// Insert into __crsql_pks table and get the pk (auto-increment key)
1548+
const pks_pk = merge_insert.insertIntoPksTableAndGetPk(api_db, table_slice, base_rowid, pk_ptr, @intCast(pk_len)) catch {
1549+
log.debug("changesUpdate: insertIntoPksTableAndGetPk for PK-only failed", .{});
1550+
return vtab.SQLITE_ERROR;
1551+
};
1552+
1553+
// Insert sentinel clock entry using pks_pk (NOT base_rowid)
1554+
const site_id_ptr_sentinel: ?[*]const u8 = @ptrCast(site_id_blob);
1555+
if (merge_stmts) |stmts| {
1556+
merge_insert.setWinnerClockCached(stmts, pks_pk, "-1", col_version, db_version, site_id_ptr_sentinel, @intCast(site_id_len), seq) catch {
1557+
log.debug("changesUpdate: setWinnerClockCached for PK-only sentinel failed", .{});
1558+
return vtab.SQLITE_ERROR;
1559+
};
1560+
} else {
1561+
merge_insert.setWinnerClock(api_db, table_slice, pks_pk, "-1", col_version, db_version, site_id_ptr_sentinel, @intCast(site_id_len), seq) catch {
1562+
log.debug("changesUpdate: setWinnerClock for PK-only sentinel failed", .{});
1563+
return vtab.SQLITE_ERROR;
1564+
};
1565+
}
1566+
1567+
rows_impacted.incrementRowsImpacted();
15271568
pRowid.* = 0;
15281569
return vtab.SQLITE_OK;
15291570
}
@@ -1534,47 +1575,41 @@ fn changesUpdate(
15341575
// Get the value from argv[5] (column 3: val)
15351576
const insert_value = toApiValue(argv[5]);
15361577

1537-
// Step 1a: Insert into base table
1538-
const new_pk = merge_insert.insertIntoBaseTable(api_db, table_slice, cid_slice, insert_value, pk_ptr, @intCast(pk_len)) catch {
1578+
// Step 1a: Insert into base table (returns the base table rowid)
1579+
const base_rowid = merge_insert.insertIntoBaseTable(api_db, table_slice, cid_slice, insert_value, pk_ptr, @intCast(pk_len)) catch {
15391580
log.debug("changesUpdate: insertIntoBaseTable failed", .{});
15401581
return vtab.SQLITE_ERROR;
15411582
};
15421583

1543-
// Step 1b: Insert into __crsql_pks table (use cached if available)
1544-
if (merge_stmts) |stmts| {
1545-
merge_insert.insertIntoPksTableCached(stmts, new_pk, pk_ptr, @intCast(pk_len)) catch {
1546-
log.debug("changesUpdate: insertIntoPksTableCached failed", .{});
1547-
return vtab.SQLITE_ERROR;
1548-
};
1549-
} else {
1550-
merge_insert.insertIntoPksTable(api_db, table_slice, new_pk, pk_ptr, @intCast(pk_len)) catch {
1551-
log.debug("changesUpdate: insertIntoPksTable failed", .{});
1552-
return vtab.SQLITE_ERROR;
1553-
};
1554-
}
1584+
// Step 1b: Insert into __crsql_pks table and get the pk (auto-increment key)
1585+
// The pk is what we use for clock table entries, NOT the base table rowid
1586+
const pks_pk = merge_insert.insertIntoPksTableAndGetPk(api_db, table_slice, base_rowid, pk_ptr, @intCast(pk_len)) catch {
1587+
log.debug("changesUpdate: insertIntoPksTableAndGetPk failed", .{});
1588+
return vtab.SQLITE_ERROR;
1589+
};
15551590

1556-
// Step 1c: Insert clock entry for the column (use cached if available)
1591+
// Step 1c: Insert clock entry for the column using pks_pk (NOT base_rowid)
15571592
const site_id_ptr_insert: ?[*]const u8 = @ptrCast(site_id_blob);
15581593
if (merge_stmts) |stmts| {
1559-
merge_insert.setWinnerClockCached(stmts, new_pk, cid_slice, col_version, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
1594+
merge_insert.setWinnerClockCached(stmts, pks_pk, cid_slice, col_version, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
15601595
log.debug("changesUpdate: setWinnerClockCached for new row failed", .{});
15611596
return vtab.SQLITE_ERROR;
15621597
};
15631598
} else {
1564-
merge_insert.setWinnerClock(api_db, table_slice, new_pk, cid_slice, col_version, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
1599+
merge_insert.setWinnerClock(api_db, table_slice, pks_pk, cid_slice, col_version, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
15651600
log.debug("changesUpdate: setWinnerClock for new row failed", .{});
15661601
return vtab.SQLITE_ERROR;
15671602
};
15681603
}
15691604

1570-
// Step 1d: Insert sentinel clock entry with the incoming cl (use cached if available)
1605+
// Step 1d: Insert sentinel clock entry with the incoming cl using pks_pk
15711606
if (merge_stmts) |stmts| {
1572-
merge_insert.setWinnerClockCached(stmts, new_pk, "-1", cl, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
1607+
merge_insert.setWinnerClockCached(stmts, pks_pk, "-1", cl, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
15731608
log.debug("changesUpdate: setWinnerClockCached for sentinel failed", .{});
15741609
return vtab.SQLITE_ERROR;
15751610
};
15761611
} else {
1577-
merge_insert.setWinnerClock(api_db, table_slice, new_pk, "-1", cl, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
1612+
merge_insert.setWinnerClock(api_db, table_slice, pks_pk, "-1", cl, db_version, site_id_ptr_insert, @intCast(site_id_len), seq) catch {
15781613
log.debug("changesUpdate: setWinnerClock for sentinel failed", .{});
15791614
return vtab.SQLITE_ERROR;
15801615
};

0 commit comments

Comments
 (0)