feat: update-point + delete engine half (iteration 9, Task 5 engine)

- wo_row_update_field: encode new value, unique re-check against a
  shadow BEFORE any mutation (violating update leaves the row
  untouched, DB_ERR_UNIQUE), index entries moved old-hash -> new-hash,
  old engine value freed; proven by test_table (unique refusal keeps
  the row, released key becomes insertable)
- WAL UPDATE record: full-row re-log, replay = replace (remove +
  re-create same id); prefix/suffix delta recorded as later
  optimization; test_wal replays insert+update to the updated state
- builtins 62 DB_UPDATE_FIELD (cid,id,field,value) and 63 DB_DELETE
  (cid,id), commit-before-ack like insert, WO_T_UNIQUE/WO_T_DB/WO_T_IO
  mapping; dispatch range 61..63; loader arities; runner mirror
- plan Task 5 marked superseded-in-part with the recorded deviation:
  the language surface (reads, queries, row views, delete statement)
  is 9b's, where the comprehension design put it -- no interim brace-
  select grammar to retire later
- gates: test_table 839/0, test_wal 102/0, 15 suites, oop-e2e 73/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-15 13:01:05 +02:00
parent 47db5b6cfe
commit 7e3bf31d36
12 changed files with 278 additions and 7 deletions

View file

@ -2604,7 +2604,7 @@ let validate_image (img : string) : string list =
golden lowering suite actually emits; 61 = DB_INSERT (arity 1:
the class-id slot — field slots are runtime-validated, same as
the C loader) *)
if c > 12 && c <> 61 then
if c > 12 && (c < 61 || c > 63) then
fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc)
else if c = 4 then begin
if b > 5 then fail (Printf.sprintf "method %d pc %d: bad element kind" i pc)
@ -2621,6 +2621,8 @@ let validate_image (img : string) : string list =
| 5 | 6 | 11 | 12 -> 2
| 10 -> 3
| 61 -> 1
| 62 -> 4
| 63 -> 2
| _ -> 0
in
if arity > 0 then begin

View file

@ -34,6 +34,40 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = id;
return 0;
}
case WO_B_DB_UPDATE_FIELD: {
uint32_t cid = (uint32_t)R[B];
uint64_t id = R[B + 1];
uint32_t field = (uint32_t)R[B + 2];
int ek = 0;
if (wo_row_update_field(db, cid, id, field, R[B + 3], msg, &ek) != 0)
return ek == DB_ERR_UNIQUE ? WO_T_UNIQUE : ek == DB_ERR_OOM ? WO_T_OOM : WO_T_DB;
wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) {
if (wo_wal_append_update(w, db, cid, id) != 0 || wo_wal_commit(w) != 0) {
*msg = "wal commit failed"; /* RAM ahead of disk: trap, do not ack */
return WO_T_IO;
}
}
R[A] = 0;
return 0;
}
case WO_B_DB_DELETE: {
uint32_t cid = (uint32_t)R[B];
uint64_t id = R[B + 1];
if (wo_row_remove(db, cid, id) != 0) {
*msg = "no such row";
return WO_T_DB;
}
wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) {
if (wo_wal_append_remove(w, cid, id) != 0 || wo_wal_commit(w) != 0) {
*msg = "wal commit failed";
return WO_T_IO;
}
}
R[A] = 0;
return 0;
}
default:
*msg = "unknown db builtin";
return WO_T_DB;

View file

@ -610,6 +610,93 @@ void wo_db_val_free(wo_db *db, uint8_t kind, uint64_t v) {
db_val_free(kind, v);
}
int wo_row_update_field(wo_db *db, uint32_t class_id, uint64_t id, uint32_t field,
uint64_t vm_val, const char **msg, int *err_kind) {
if (err_kind) *err_kind = DB_ERR_MISC;
db_row *r = wo_row_ptr(db, class_id, id);
if (!r) {
*msg = "no such row";
return -1;
}
const wo_classdesc *c = &db->classes[class_id];
if (field >= c->field_cnt) {
*msg = "no such field";
return -1;
}
db_table *t = &db->tables[class_id];
int ok = 1;
uint64_t nv = db_val_encode(db->classes, c->kinds[field], vm_val, &ok, msg);
if (!ok) {
if (err_kind) *err_kind = DB_ERR_BADKIND;
return -1;
}
/* indexes containing this column: unique checks against the NEW value
run first, against a shadow of the row, before anything mutates */
uint64_t old = r->slots[field];
r->slots[field] = nv;
for (uint32_t x = 0; x < t->index_cnt; x++) {
db_index *ix = &t->indexes[x];
if (!(ix->flags & 1u)) continue;
int touches = 0;
for (uint32_t i = 0; i < ix->col_cnt; i++)
if (ix->cols[i] == field) touches = 1;
if (!touches) continue;
db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 0);
if (!b) continue;
for (uint32_t i = 0; i < b->len; i++) {
if (b->ids[i] == id) continue;
db_row *other = wo_row_ptr(db, class_id, b->ids[i]);
if (other && idx_cols_equal(c, ix, r, other)) {
r->slots[field] = old; /* untouched, promised */
db_val_free(c->kinds[field], nv);
if (err_kind) *err_kind = DB_ERR_UNIQUE;
*msg = "unique index violation";
return -1;
}
}
}
/* commit: fix every index containing the column (old entry out under
the OLD value's hash, new entry in), then free the old value */
r->slots[field] = old;
for (uint32_t x = 0; x < t->index_cnt; x++) {
db_index *ix = &t->indexes[x];
int touches = 0;
for (uint32_t i = 0; i < ix->col_cnt; i++)
if (ix->cols[i] == field) touches = 1;
if (!touches) continue;
db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 0);
if (b)
for (uint32_t i = 0; i < b->len; i++)
if (b->ids[i] == id) {
b->ids[i] = b->ids[--b->len];
break;
}
}
r->slots[field] = nv;
for (uint32_t x = 0; x < t->index_cnt; x++) {
db_index *ix = &t->indexes[x];
int touches = 0;
for (uint32_t i = 0; i < ix->col_cnt; i++)
if (ix->cols[i] == field) touches = 1;
if (!touches) continue;
db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 1);
if (b) {
if (b->len == b->cap) {
uint32_t ncap = b->cap ? b->cap * 2 : 4;
uint64_t *ni = realloc(b->ids, (size_t)ncap * 8u);
if (ni) {
b->ids = ni;
b->cap = ncap;
}
}
if (b->len < b->cap) b->ids[b->len++] = id;
}
}
db_val_free(c->kinds[field], old);
if (err_kind) *err_kind = DB_ERR_NONE;
return 0;
}
int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id) {
if (class_id >= db->class_cnt) return -1;
db_table *t = &db->tables[class_id];

View file

@ -150,6 +150,14 @@ int wo_row_read(wo_db *db, wo_rt *rt, uint32_t class_id, uint64_t id,
* it. 0 ok, -1 no such row. */
int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id);
/* Update one field in place (iteration 9 Task 5): encode the VM value,
* swap it into the slot, keep every index containing that column honest —
* remove-old/add-new with the unique re-check running BEFORE anything
* mutates, so a violating update leaves the row untouched. 0 ok, -1 no
* such row / bad field, DB_ERR_* codes via *err_kind like insert. */
int wo_row_update_field(wo_db *db, uint32_t class_id, uint64_t id, uint32_t field,
uint64_t vm_val, const char **msg, int *err_kind);
/* Borrowed row pointer for engine-internal callers (the WAL writes a row's
* encoded bytes; indexes read key slots). NULL = no such row. NEVER handed
* to the VM. */

View file

@ -356,6 +356,20 @@ int wo_wal_append_insert(wo_wal *w, wo_db *db, uint32_t class_id, uint64_t id) {
return rc;
}
int wo_wal_append_update(wo_wal *w, wo_db *db, uint32_t class_id, uint64_t id) {
db_row *r = wo_row_ptr(db, class_id, id);
if (!r) return -1;
wbuf p = {0};
wput_u8(&p, WO_WAL_UPDATE);
wput_u32(&p, class_id);
wput_u64(&p, id);
const wo_classdesc *c = &db->classes[class_id];
for (uint32_t i = 0; i < c->field_cnt; i++) enc_val(&p, db->classes, c->kinds[i], r->slots[i]);
int rc = stage(w, &p);
free(p.b);
return rc;
}
int wo_wal_append_remove(wo_wal *w, uint32_t class_id, uint64_t id) {
wbuf p = {0};
wput_u8(&p, WO_WAL_REMOVE);
@ -390,7 +404,12 @@ static int apply_record(wo_db *db, const uint8_t *payload, uint32_t len) {
uint64_t id = rd_u64(&r);
if (r.bad || cid >= db->class_cnt) return -1;
if (kind == WO_WAL_REMOVE) return wo_row_remove(db, cid, id);
if (kind != WO_WAL_INSERT) return -1; /* UPDATE lands with Task 5 */
if (kind != WO_WAL_INSERT && kind != WO_WAL_UPDATE) return -1;
if (kind == WO_WAL_UPDATE) {
/* replace: the row must exist (its insert precedes its update in a
correct log); anything else is corruption */
if (wo_row_remove(db, cid, id) != 0) return -1;
}
db_row *row = wo_row_create_raw(db, cid, id);
if (!row) return -1;
const wo_classdesc *c = &db->classes[cid];

View file

@ -66,6 +66,10 @@ void wo_wal_close(wo_wal *w);
* 0 ok, -1 OOM / no such row. */
int wo_wal_append_insert(wo_wal *w, wo_db *db, uint32_t class_id, uint64_t id);
int wo_wal_append_remove(wo_wal *w, uint32_t class_id, uint64_t id);
/* UPDATE re-logs the whole row (KISS: replay replaces — remove + re-create
* with the same id; the prefix/suffix delta trick from the survey is a
* later optimization, recorded). Call AFTER the RAM update. */
int wo_wal_append_update(wo_wal *w, wo_db *db, uint32_t class_id, uint64_t id);
/* Write the staged batch and fdatasync — the ack line. Empty batch = ok,
* no syscall. 0 ok, -1 write/sync failure (the batch stays staged). */

View file

@ -1,6 +1,6 @@
# DB Engine Binding Implementation Plan
> **Status: 🔄 in progress — Tasks 1–3 done 2026-08-15** (story iteration 9) — class-shaped tables, typed WAL + recovery, `insert`/`select` execution. Story iteration 9b (`@table` relations + language-integrated query) follows it and needs a spec brainstormed first. Board: [00-status.md](../../00-status.md)
> **Status: 🔄 in progress — Tasks 1–4 done, Task 5 engine half done 2026-08-15** (story iteration 9) — class-shaped tables, typed WAL + recovery, `insert`/`select` execution. Story iteration 9b (`@table` relations + language-integrated query) follows it and needs a spec brainstormed first. Board: [00-status.md](../../00-status.md)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
@ -113,7 +113,19 @@ sanitizers included. `database/` gets its own CODE-LOGIC.md as code lands.
- [ ] Implement; green.
- [ ] Record commit draft: `feat(runtime): secondary indexes — per-shard hash indexes maintained only inside the row API, @unique constraint trap, replay rebuild; doctrine enforced by construction.`
### Task 5: `select` subset + cross-shard point reads
### Task 5 🔄: `select` subset + cross-shard point reads — **superseded in part (2026-08-15)**
> **Deviation, recorded:** the 9b design (spec'd after this plan was
> written) makes compiler-lowered comprehension queries THE select
> surface — a brace-select subset built here would be a second grammar
> retired months later. So Task 5's ENGINE half landed now (update-point
> through the choke point with unique re-check, delete, the WAL UPDATE
> record with replace-on-replay, builtins 62/63 — `test_table` 839/0,
> `test_wal` 102/0), and the LANGUAGE half (reads, queries, row views,
> the delete statement) is the 9b plan's Tasks 1–5, where it belongs.
> Cross-shard point reads wait for iteration 8's mailboxes, as written.
**Concept & reason:** the milestone query subset, semantics per the C++ `wo-db` reference where they overlap: select-by-id; select with a WHERE conjunction over indexed fields (index-backed) or a full shard scan (explicitly allowed, explicitly slower); dotted-path field access in the projection; results materialize as VM objects (rows decode back through the field-encoding rules — the Task-1 doc's table read in reverse). Local rows resolve inline; a by-id read of a foreign row hops once via the plan-4 mailbox (point ops hop once; the requesting job pumps its inbox while waiting — never blocks). List queries stay shard-local in this plan; scatter-gather fan-out is future work, stated in the doc. `update` limited to point-by-id field sets (the method-transaction pattern the pricing demo uses); no joins, no aggregations beyond the existing builtins, no RETURNING chaining — all named as out-of-scope in the binding doc.

View file

@ -70,7 +70,7 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
if (C == WO_B_JSON_ENCODE || C == WO_B_JSON_DECODE)
return wo_builtin_json(vm, R, ins, msg);
if (C >= WO_B_SYS_FIRST && C <= WO_B_PROC_RUN) return wo_builtin_sys(vm, R, ins, msg);
if (C == WO_B_DB_INSERT) return wo_builtin_db(vm, R, ins, msg);
if (C >= WO_B_DB_INSERT && C <= WO_B_DB_DELETE) return wo_builtin_db(vm, R, ins, msg);
switch (C) {
case WO_B_NOW: { /* wall-clock milliseconds */
struct timespec ts;

View file

@ -42,6 +42,8 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
the class table (db.c / wo_row_insert). Same trust level as the
kind-immediate builtins' B nibble. */
[WO_B_DB_INSERT] = 1,
[WO_B_DB_UPDATE_FIELD] = 4,
[WO_B_DB_DELETE] = 2,
[WO_B_NOW] = 0, [WO_B_PRINT] = 1, [WO_B_PRINT_INT] = 1,
[WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2,
[WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1,

View file

@ -295,8 +295,15 @@ enum {
* failures trap WO_T_DB; a failed WAL commit traps WO_T_IO (the write
* was applied to RAM but never acknowledged). */
WO_B_DB_INSERT = 61,
/* DB_UPDATE_FIELD: R[B] = class id, R[B+1] = row id, R[B+2] = field
* index, R[B+3] = the value. R[A] = 0. Unique violation traps
* WO_T_UNIQUE with the row untouched. */
WO_B_DB_UPDATE_FIELD = 62,
/* DB_DELETE: R[B] = class id, R[B+1] = row id. R[A] = 0. A missing row
* traps WO_T_DB (deleting what is not there is a fault, not a no-op). */
WO_B_DB_DELETE = 63,
};
#define WO_B_MAX 61u
#define WO_B_MAX 63u
/* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS

View file

@ -161,6 +161,66 @@ static void test_slab_growth_and_reuse(void) {
wo_rt_destroy(&rt);
}
/* Task 5: single-field update through the choke point — value swapped,
* indexes moved, unique violations leave the row untouched. Class 3 in a
* local table: User { email: Text @unique-ish } via idx metadata. */
static const uint8_t user_kinds[] = {WO_K_TEXT, WO_K_SCALAR};
static const uint32_t user_idx_meta[] = {1 /*unique*/, 1, 0 /*col: email*/,
0 /*non-unique*/, 1, 1 /*col: n*/};
static const wo_classdesc UCLASSES[] = {
{.name = 0, .flags = 0, .field_cnt = 2, .kinds = user_kinds, .idx_cnt = 2,
.idx_meta = user_idx_meta},
};
static void test_update_field(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 20, UCLASSES, 1), 0);
wo_db db;
T_EQ(wo_db_init(&db, UCLASSES, 1, 0, 1), 0);
const char *msg = "";
int ek = 0;
wo_str *e1 = wo_str_new(&rt, "a@x", 3);
wo_str *e2 = wo_str_new(&rt, "b@x", 3);
uint64_t v1[2] = {(uint64_t)(uintptr_t)e1, 10};
uint64_t v2[2] = {(uint64_t)(uintptr_t)e2, 20};
uint64_t a = wo_row_insert(&db, 0, v1, &msg, NULL);
uint64_t b = wo_row_insert(&db, 0, v2, &msg, NULL);
T_CHECK(a && b);
/* scalar update: value moves, non-unique index follows */
T_EQ(wo_row_update_field(&db, 0, a, 1, 99, &msg, &ek), 0);
uint64_t out[2];
T_EQ(wo_row_read(&db, &rt, 0, a, out, &msg), 0);
T_EQ(out[1], 99);
wo_str_free(&rt, (wo_str *)(uintptr_t)out[0]);
/* unique violation: updating a's email to b's must refuse, row untouched */
wo_str *dupe = wo_str_new(&rt, "b@x", 3);
T_EQ(wo_row_update_field(&db, 0, a, 0, (uint64_t)(uintptr_t)dupe, &msg, &ek), -1);
T_EQ(ek, DB_ERR_UNIQUE);
T_EQ(wo_row_read(&db, &rt, 0, a, out, &msg), 0);
wo_str *still = (wo_str *)(uintptr_t)out[0];
T_CHECK(still->len == 3 && memcmp(still->data, "a@x", 3) == 0);
wo_str_free(&rt, still);
/* legal text update: old engine value freed (ASan), index moved — the
old email becomes free for someone else */
wo_str *fresh = wo_str_new(&rt, "c@x", 3);
T_EQ(wo_row_update_field(&db, 0, a, 0, (uint64_t)(uintptr_t)fresh, &msg, &ek), 0);
wo_str *take_a = wo_str_new(&rt, "a@x", 3);
uint64_t v3[2] = {(uint64_t)(uintptr_t)take_a, 30};
uint64_t cid = wo_row_insert(&db, 0, v3, &msg, &ek);
T_CHECK(cid != 0); /* "a@x" released by the update */
wo_drop_obj(&rt, (wo_hdr *)e1);
wo_drop_obj(&rt, (wo_hdr *)e2);
wo_drop_obj(&rt, (wo_hdr *)dupe);
wo_drop_obj(&rt, (wo_hdr *)fresh);
wo_drop_obj(&rt, (wo_hdr *)take_a);
wo_db_destroy(&db);
wo_rt_destroy(&rt);
}
static void test_misuse(void) {
const char *msg = "";
wo_db db;
@ -177,6 +237,7 @@ int main(void) {
test_roundtrip_all_kinds();
test_id_interleave_across_shards();
test_slab_growth_and_reuse();
test_update_field();
test_misuse();
return t_report("test_table");
}

View file

@ -14,6 +14,7 @@
#include <time.h>
#include <unistd.h>
#include "gc.h"
#include "obj.h"
#include "t.h"
#include "table.h"
@ -73,13 +74,47 @@ static void test_roundtrip_replay(void) {
uint64_t fresh = wo_row_insert(&db2, 0, vals, &msg, NULL);
T_CHECK(fresh > ids[2]);
wo_db_destroy(&db2);
wo_rt_destroy(&rt);
/* update record: re-log, replay replaces */
{
char upath[128];
snprintf(upath, sizeof upath, "%s/upd.wal", g_dir);
wo_db du;
T_EQ(wo_db_init(&du, CLASSES, 1, 0, 1), 0);
wo_wal wu;
T_EQ(wo_wal_open(&wu, upath, 0), 0);
wo_str *s1 = wo_str_new(&rt, "old", 3);
uint64_t uv[2] = {7, (uint64_t)(uintptr_t)s1};
uint64_t uid = wo_row_insert(&du, 0, uv, &msg, NULL);
T_EQ(wo_wal_append_insert(&wu, &du, 0, uid), 0);
int ek = 0;
wo_str *s2 = wo_str_new(&rt, "new!", 4);
T_EQ(wo_row_update_field(&du, 0, uid, 1, (uint64_t)(uintptr_t)s2, &msg, &ek), 0);
T_EQ(wo_row_update_field(&du, 0, uid, 0, 8, &msg, &ek), 0);
T_EQ(wo_wal_append_update(&wu, &du, 0, uid), 0);
T_EQ(wo_wal_commit(&wu), 0);
wo_wal_close(&wu);
wo_db_destroy(&du);
wo_db db4;
T_EQ(wo_db_init(&db4, CLASSES, 1, 0, 1), 0);
T_EQ(wo_wal_replay(upath, &db4), 2);
uint64_t uo[2];
T_EQ(wo_row_read(&db4, &rt, 0, uid, uo, &msg), 0);
T_EQ(uo[0], 8);
wo_str *us = (wo_str *)(uintptr_t)uo[1];
T_CHECK(us->len == 4 && memcmp(us->data, "new!", 4) == 0);
wo_str_free(&rt, us);
wo_drop_obj(&rt, (wo_hdr *)s1);
wo_drop_obj(&rt, (wo_hdr *)s2);
wo_db_destroy(&db4);
}
/* replay of a missing file is a fresh boot, not an error */
wo_db db3;
T_EQ(wo_db_init(&db3, CLASSES, 1, 0, 1), 0);
T_EQ(wo_wal_replay("/nonexistent/nope.wal", &db3), 0);
wo_db_destroy(&db3);
wo_rt_destroy(&rt);
}
static void test_torn_tail(void) {