code wiki / wiki / nx_wiki_version.nx

nx_wiki_version.nx source

↩ module page · 458 lines · 19025 B

1// nx_wiki_version.nx -- wiki R2: page VERSIONING (history / diff / rollback). 2// 3// Builds the version-control surface ON TOP of the R1 save backbone 4// (wiki/nx_wiki_page_save). R1 already writes, on EVERY authed save, two rows 5// into the content-addressed append-only seg_store under the 6// "knowledge/store/wikipage-" prefix: 7// wikicur:<slug> = the current markdown bytes 8// wikirev:<slug>:<epoch> = "author=<a>\nepoch=<e>\n" + the content 9// The immutable wikirev:* rows ARE the history substrate this module walks. No 10// new persistence model is invented: history is read from those rows, diff is 11// computed over two of them, and rollback REUSES the R1 save path so it lands as 12// a NEW revision (additive-data law / cardinal 13 -- a restore never deletes or 13// mutates a prior revision; it appends one equal to the old content). 14// 15// ENUMERATION (schema-first, verified against nx_seg_store.nx): 16// The seg_store exposes ss_get / ss_scan / ss_hget for ONE EXACT key, but no 17// prefix-key enumerator. The on-disk .docs entry format is public and stable 18// (kind:u8 | klen:u32be | key | vlen:u32be | val -- see ss_scan_seglist), and 19// ss_manifest + ss_readall + ss_r32 are public top-level helpers. So this 20// module walks the live manifest's segments and collects every entry whose 21// key begins with "wikirev:<slug>:", parsing the epoch suffix for ordering. 22// Each revision key is written exactly ONCE (additive), so it appears once. 23// 24// DIFF: no nx_diff organ exists in the wiki tree, so a small, correct 25// line-level diff lives here: hash each line (FNV-1a), compute the LCS over 26// the line-hash sequences (classic O(a*b) DP), then emit unified-ish 27// "- removed" / "+ added" lines for the non-matching lines. Identical 28// revisions => empty diff. 29// 30// COMPOSES (avoid-duplicate-primitives cardinal; each imported ONCE): 31// hub/nx_modern_auth_flow nx_modern_auth_validate_session (rollback gate) 32// wiki/nx_wiki_page_save nx_wiki_page_save (R1 save = additive rollback) 33// + nx_wps_prefix / nx_wps_rev_key (key format) 34// nx_seg_store ss_manifest / ss_readall / ss_r32 (enumerate) 35// + ss_open / ss_hget (read one revision) 36// 37// COMPOSED BY: 38// runtime/_hdl_build/_wiki_version_gate unit gate (history/diff/rollback + 39// additive proof + unauth liar-kill) 40// 41// Status: R2. 2026-06-14. license_tier: ORIGINAL 42 43import "nx_syscalls.nx" 44import "hub/nx_modern_auth_flow.nx" 45import "wiki/nx_wiki_page_save.nx" 46import "nx_seg_store.nx" 47 48// ===== Sealed verdict surface (codes 2920-2939) ================================================= 49const NX_WV_OK: i64 = 0 50const NX_WV_REFUSED: i64 = 2920 // rollback auth gate refused 51const NX_WV_BAD_INPUT: i64 = 2921 52const NX_WV_NOT_FOUND: i64 = 2922 // target revision absent 53const NX_WV_OVERFLOW: i64 = 2923 54const NX_WV_SAVE_FAILED: i64 = 2924 // R1 save path returned an error 55 56// ===== Named constants (M7 / no magic numbers) ============================= 57const NX_WV_MAX_REVS: i64 = 4096 // versions surfaced per slug per call 58const NX_WV_KEY_CAP: i64 = 320 // "wikirev:" + slug + ":" + epoch + NUL 59const NX_WV_SEG_CAP: i64 = 4096 // live-manifest segment slots scanned 60 61// ===== small local helpers (counted, NUL-free internals) =================== 62 63// counted-prefix test: does key[0..klen) start with pfx[0..pn)? 64func _wv_has_prefix(key: *u8, klen: i64, pfx: *u8, pn: i64) -> i64 { 65 if klen < pn { return 0 } 66 var i: i64 = 0 67 while i < pn { if key[i] != pfx[i] { return 0 } i = i + 1 } 68 return 1 69} 70 71// parse a non-negative decimal from b[off..end); returns the value (>=0) or -1 72// if any byte is a non-digit (used to read the <epoch> suffix of a rev key). 73func _wv_parse_dec(b: *u8, off: i64, end: i64) -> i64 { 74 if off >= end { return 0 - 1 } 75 var v: i64 = 0 76 var i: i64 = off 77 while i < end { 78 let c: i64 = b[i] 79 if c < 48 { return 0 - 1 } 80 if c > 57 { return 0 - 1 } 81 v = v * 10 + (c - 48) 82 i = i + 1 83 } 84 return v 85} 86 87// strip the "author=..\nepoch=..\n" header off a wikirev value, copying the 88// content tail into out (cap-bounded). Returns content length, or -1 on 89// overflow / malformed (no header => treat the whole value as content). 90// The header ends at the SECOND newline (after "epoch=<e>"); R1 always writes 91// exactly two header lines, so the content begins right after the 2nd '\n'. 92func _wv_strip_header(val: *u8, vn: i64, out: *u8, cap: i64) -> i64 { 93 var nl: i64 = 0 94 var start: i64 = 0 95 var i: i64 = 0 96 while i < vn { 97 if val[i] == (10 as u8) { 98 nl = nl + 1 99 if nl == 2 { start = i + 1; i = vn } 100 } 101 i = i + 1 102 } 103 // no/short header -> emit whole value (defensive; cardinal 12 at boundary) 104 if nl < 2 { start = 0 } 105 let n: i64 = vn - start 106 if n < 0 { return 0 - 1 } 107 if n > cap { return 0 - 1 } 108 var j: i64 = 0 109 while j < n { out[j] = val[start + j]; j = j + 1 } 110 return n 111} 112 113// ===== REVISION ENUMERATION (the history scan) ============================= 114// 115// Walk the live manifest's segments, reading each seg-<id>.docs and collecting 116// every entry whose key starts with "wikirev:<slug>:". For each hit we record 117// the epoch (parsed from the key suffix) and the value's content length. The 118// keys arrive in segment (chronological) order; we sort by epoch ascending so 119// the caller always sees oldest -> newest regardless of compaction. 120// 121// epochs[i] / clens[i] are filled for i in [0, count). Returns the count 122// (capped at NX_WV_MAX_REVS; a fuller history is the compaction/paging rung). 123// Pure read -- mutates nothing in the store. 124func nx_wiki_revscan(slug: *u8, slug_n: i64, 125 epochs: *i64, clens: *i64, max: i64) -> i64 { 126 if (slug as i64) == 0 { return 0 } 127 if slug_n < 1 { return 0 } 128 129 // build the per-slug rev key PREFIX "wikirev:<slug>:" once. Use an INLINE 130 // string literal copied through ss_cat (the exact idiom nx_wps_rev_key uses) 131 // -- indexing a `const *u8` byte-by-byte does NOT yield the literal bytes. 132 let pfx: *u8 = sys_mmap(NX_WV_KEY_CAP) 133 var po: i64 = 0 134 po = ss_cat(pfx, po, "wikirev:" as *u8) 135 var si: i64 = 0 136 while si < slug_n { pfx[po + si] = slug[si]; si = si + 1 } 137 po = po + slug_n 138 pfx[po] = 58 as u8 // ':' 139 po = po + 1 140 let pfx_n: i64 = po // length of "wikirev:<slug>:" 141 142 // list the live segments under the wikipage prefix 143 let segs: *i64 = sys_mmap(8 * NX_WV_SEG_CAP) as *i64 144 let ns: i64 = ss_manifest_cap(nx_wps_prefix(), segs, NX_WV_SEG_CAP) 145 146 var cnt: i64 = 0 147 var s: i64 = 0 148 while s < ns { 149 // path = "<prefix><seg-name>.docs" 150 let path: *u8 = sys_mmap(512) 151 var o: i64 = 0 152 o = ss_cat(path, o, nx_wps_prefix()) 153 o = ss_cat(path, o, segs[s] as *u8) 154 o = ss_cat(path, o, ".docs" as *u8) 155 path[o] = 0 as u8 156 let szp: *i64 = sys_mmap(16) as *i64 157 let b: *u8 = ss_readall(path, szp) 158 let sz: i64 = szp[0] 159 if sz > 0 { 160 var i: i64 = 0 161 while i + 9 <= sz { 162 let kind: i64 = b[i] 163 let kl: i64 = ss_r32(b, i + 1) 164 let koff: i64 = i + 5 165 let vl: i64 = ss_r32(b, koff + kl) 166 let voff: i64 = koff + kl + 4 167 // only PUT entries (kind 1) whose key carries our rev prefix. 168 // R1 never tombstones a wikirev, so this is exact. 169 if kind == 1 { 170 if _wv_has_prefix((b as i64 + koff) as *u8, kl, pfx, pfx_n) == 1 { 171 let ep: i64 = _wv_parse_dec(b, koff + pfx_n, koff + kl) 172 if ep >= 0 { 173 if cnt < max { 174 epochs[cnt] = ep 175 clens[cnt] = vl // value len incl. header 176 cnt = cnt + 1 177 } 178 } 179 } 180 } 181 i = voff + vl 182 } 183 } 184 s = s + 1 185 } 186 187 // sort (epochs, clens) ascending by epoch (insertion sort; rev counts are 188 // tiny per page). Stable enough -- epochs are unique per save. 189 var a: i64 = 1 190 while a < cnt { 191 let ke: i64 = epochs[a] 192 let kc: i64 = clens[a] 193 var b2: i64 = a - 1 194 var go: i64 = 1 195 while go == 1 { 196 if b2 < 0 { go = 0 } else { 197 if epochs[b2] > ke { 198 epochs[b2 + 1] = epochs[b2] 199 clens[b2 + 1] = clens[b2] 200 b2 = b2 - 1 201 } else { go = 0 } 202 } 203 } 204 epochs[b2 + 1] = ke 205 clens[b2 + 1] = kc 206 a = a + 1 207 } 208 return cnt 209} 210 211// ===== 1. HISTORY ========================================================== 212// 213// nx_wiki_history(slug): enumerate the revisions of a slug in epoch order and 214// write them into the caller's epochs[]/clens[] buffers (content-length per 215// revision; the cid surrogate -- a richer cid is the seg_store record id). Also 216// PRINTS one "WIKIHIST" line with the count to stdout. Returns the count. 217func nx_wiki_history(slug: *u8, slug_n: i64, 218 epochs: *i64, clens: *i64, max: i64) -> i64 { 219 let n: i64 = nx_wiki_revscan(slug, slug_n, epochs, clens, max) 220 // observable line (WHAT happened + entity + count; cardinal 18) 221 sys_write(1, "WIKIHIST slug=" as *u8, 13) 222 sys_write(1, slug, slug_n) 223 sys_write(1, " revs=" as *u8, 6) 224 let nb: *u8 = sys_mmap(28) 225 var m: i64 = n 226 let t: *u8 = sys_mmap(28); var k: i64 = 0 227 if m == 0 { t[0] = 48 as u8; k = 1 } 228 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 229 var x: i64 = 0 230 while x < k { nb[x] = t[k - 1 - x]; x = x + 1 } 231 sys_write(1, nb, k) 232 sys_write(1, "\n" as *u8, 1) 233 return n 234} 235 236// ===== 2. GET ONE REVISION'S CONTENT ======================================= 237// 238// nx_wiki_rev_get(slug, epoch): read wikirev:<slug>:<epoch> via the seg_store 239// handle, strip the "author=..\nepoch=..\n" header, copy the content into out. 240// Returns the content byte length (>=0), or -1 if the revision is absent / 241// overflows the caller's cap. Pure read. 242func nx_wiki_rev_get(slug: *u8, slug_n: i64, epoch: i64, 243 out: *u8, cap: i64) -> i64 { 244 if (slug as i64) == 0 { return 0 - 1 } 245 if slug_n < 1 { return 0 - 1 } 246 let revk: *u8 = sys_mmap(NX_WV_KEY_CAP) 247 nx_wps_rev_key(slug, slug_n, epoch, revk) 248 let h: *i64 = ss_open(nx_wps_prefix()) 249 if (h as i64) == 0 { return 0 - 1 } 250 let pp: *i64 = sys_mmap(16) as *i64 251 let pl: *i64 = sys_mmap(16) as *i64 252 if ss_hget(h, revk, pp, pl) != 1 { return 0 - 1 } 253 return _wv_strip_header(pp[0] as *u8, pl[0], out, cap) 254} 255 256// ===== line-level diff primitives ========================================== 257 258// FNV-1a over a line b[off..off+n) 259func _wv_line_hash(b: *u8, off: i64, n: i64) -> i64 { 260 var h: i64 = 1469598103934665603 261 var i: i64 = 0 262 while i < n { 263 h = h ^ (b[off + i] as i64) 264 h = h * 1099511628211 265 i = i + 1 266 } 267 return h 268} 269 270// split buf[0..n) into lines: starts[i]/lens[i] (line WITHOUT the trailing 271// '\n'); hashes[i] = FNV of the line. A trailing partial line (no final '\n') 272// counts as a line. Returns the line count (capped at `cap`). 273func _wv_split_lines(buf: *u8, n: i64, 274 starts: *i64, lens: *i64, hashes: *i64, cap: i64) -> i64 { 275 var cnt: i64 = 0 276 var ls: i64 = 0 277 var i: i64 = 0 278 while i < n { 279 if buf[i] == (10 as u8) { 280 if cnt < cap { 281 starts[cnt] = ls 282 lens[cnt] = i - ls 283 hashes[cnt] = _wv_line_hash(buf, ls, i - ls) 284 cnt = cnt + 1 285 } 286 ls = i + 1 287 } 288 i = i + 1 289 } 290 if ls < n { 291 if cnt < cap { 292 starts[cnt] = ls 293 lens[cnt] = n - ls 294 hashes[cnt] = _wv_line_hash(buf, ls, n - ls) 295 cnt = cnt + 1 296 } 297 } 298 return cnt 299} 300 301// emit one diff line: prefix char (45 '-' / 43 '+') then the source line bytes 302// then '\n', into out at off. Returns new off (cap-bounded; truncates a line 303// that would overflow rather than smash memory). 304func _wv_emit(out: *u8, off: i64, cap: i64, pfx: i64, 305 src: *u8, lstart: i64, llen: i64) -> i64 { 306 var o: i64 = off 307 if o + 1 > cap { return o } 308 out[o] = pfx as u8 309 o = o + 1 310 var j: i64 = 0 311 while j < llen { 312 if o >= cap { return o } 313 out[o] = src[lstart + j] 314 o = o + 1 315 j = j + 1 316 } 317 if o < cap { out[o] = 10 as u8; o = o + 1 } 318 return o 319} 320 321// ===== 3. DIFF ============================================================= 322// 323// nx_wiki_diff(slug, epoch_a, epoch_b): line-level diff of revision A vs B. 324// Reads both revisions' content, splits into lines, computes the LCS over the 325// line-hash sequences (classic DP), then walks the two sequences emitting: 326// "- <line>" for lines present in A but not on the LCS (removed) 327// "+ <line>" for lines present in B but not on the LCS (added) 328// Common lines (on the LCS) are not emitted (unified-ish, context-free). Two 329// IDENTICAL revisions => zero emitted bytes (empty diff). Returns the diff 330// byte length written into out (>=0), or -1 if a revision is missing. 331// 332// The "- " / "+ " markers carry a SPACE after the sign for readability, so an 333// emitted line is "-" + " removed" = "- removed". 334func nx_wiki_diff(slug: *u8, slug_n: i64, epoch_a: i64, epoch_b: i64, 335 out: *u8, cap: i64) -> i64 { 336 // bounded scratch for both revisions' content 337 let abuf: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16) 338 let bbuf: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16) 339 let an: i64 = nx_wiki_rev_get(slug, slug_n, epoch_a, abuf, NX_WPS_MAX_CONTENT_LEN) 340 if an < 0 { return 0 - 1 } 341 let bn: i64 = nx_wiki_rev_get(slug, slug_n, epoch_b, bbuf, NX_WPS_MAX_CONTENT_LEN) 342 if bn < 0 { return 0 - 1 } 343 344 let maxl: i64 = 4096 // lines per side (data-driven cap) 345 let as_: *i64 = sys_mmap(8 * maxl) as *i64 346 let al_: *i64 = sys_mmap(8 * maxl) as *i64 347 let ah_: *i64 = sys_mmap(8 * maxl) as *i64 348 let bs_: *i64 = sys_mmap(8 * maxl) as *i64 349 let bl_: *i64 = sys_mmap(8 * maxl) as *i64 350 let bh_: *i64 = sys_mmap(8 * maxl) as *i64 351 let na: i64 = _wv_split_lines(abuf, an, as_, al_, ah_, maxl) 352 let nb: i64 = _wv_split_lines(bbuf, bn, bs_, bl_, bh_, maxl) 353 354 // LCS DP over line hashes. dp[(i)*(nb+1)+j] = LCS length of A[i..] , B[j..]. 355 let w: i64 = nb + 1 356 let dp: *i64 = sys_mmap(8 * (na + 1) * (nb + 1)) as *i64 357 var i: i64 = na - 1 358 while i >= 0 { 359 var j: i64 = nb - 1 360 while j >= 0 { 361 var best: i64 = 0 362 if ah_[i] == bh_[j] { 363 best = dp[(i + 1) * w + (j + 1)] + 1 364 } else { 365 let d1: i64 = dp[(i + 1) * w + j] 366 let d2: i64 = dp[i * w + (j + 1)] 367 if d1 >= d2 { best = d1 } else { best = d2 } 368 } 369 dp[i * w + j] = best 370 j = j - 1 371 } 372 i = i - 1 373 } 374 375 // walk the DP diagonally, emitting removed/added lines off the LCS path 376 var o: i64 = 0 377 var ia: i64 = 0 378 var jb: i64 = 0 379 while ia < na { 380 if jb < nb { 381 if ah_[ia] == bh_[jb] { 382 // common line -> on the LCS, skip both (context-free) 383 ia = ia + 1 384 jb = jb + 1 385 } else { 386 if dp[(ia + 1) * w + jb] >= dp[ia * w + (jb + 1)] { 387 o = _wv_emit(out, o, cap, 45, abuf, as_[ia], al_[ia]) // '-' 388 ia = ia + 1 389 } else { 390 o = _wv_emit(out, o, cap, 43, bbuf, bs_[jb], bl_[jb]) // '+' 391 jb = jb + 1 392 } 393 } 394 } else { 395 o = _wv_emit(out, o, cap, 45, abuf, as_[ia], al_[ia]) // '-' 396 ia = ia + 1 397 } 398 } 399 while jb < nb { 400 o = _wv_emit(out, o, cap, 43, bbuf, bs_[jb], bl_[jb]) // '+' 401 jb = jb + 1 402 } 403 return o 404} 405 406// ===== 4. ROLLBACK (auth-gated, ADDITIVE) ================================== 407// 408// nx_wiki_rollback(slug, target_epoch, session_token): restore the page to the 409// content of revision <target_epoch>. 410// 1. AUTH GATE (security-critical; REUSES the proven validator -- the EXACT 411// check R1 save uses): a null / forged / expired token => NX_WV_REFUSED 412// and NOTHING is written (the store is left untouched -- liar-kill). 413// 2. Read the target revision's content (header stripped). 414// 3. Call the R1 save path (nx_wiki_page_save) with that content + the SAME 415// valid token, so it persists as a NEW wikirev:<slug>:<now> AND updates 416// wikicur:<slug>. The old revisions are NEVER deleted or mutated -- the 417// rollback is purely additive (cardinal 13): the new head equals the old 418// content while every prior revision still stands as history. 419// 420// `store` may be 0 (persist-only / headless). `now_unix_s` stamps the new rev. 421// Returns NX_WV_OK on success; NX_WV_REFUSED if auth fails; other -verdicts on 422// missing target / save error. 423func nx_wiki_rollback( 424 store: *NxWikiDocStore, 425 ctx: *NxAuthContext, 426 slug: *u8, slug_n: i64, 427 target_epoch: i64, 428 session_token: *u8, token_n: i64, 429 now_unix_s: i64 430) -> i64 { 431 if (slug as i64) == 0 { return 0 - NX_WV_BAD_INPUT } 432 if slug_n < 1 { return 0 - NX_WV_BAD_INPUT } 433 434 // ---- 1. AUTH GATE (reused validator; same as R1 save) ---- 435 if (ctx as i64) == 0 { return 0 - NX_WV_REFUSED } 436 let auth_rc: i64 = nx_modern_auth_validate_session( 437 ctx, session_token, token_n, now_unix_s, 438 (0 as i64) as *u8, 0, (0 as i64) as *i64) 439 if auth_rc != NX_MAUTH_OK { return 0 - NX_WV_REFUSED } 440 441 // ---- 2. read the target revision's content ---- 442 let content: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16) 443 let cn: i64 = nx_wiki_rev_get(slug, slug_n, target_epoch, 444 content, NX_WPS_MAX_CONTENT_LEN) 445 if cn < 0 { return 0 - NX_WV_NOT_FOUND } 446 447 // ---- 3. R1 save = a NEW revision equal to the old content (additive) ---- 448 // nx_wiki_page_save re-validates the SAME token (defence in depth at its own 449 // boundary) and appends wikirev:<slug>:<now> + updates wikicur:<slug>. 450 let save_rc: i64 = nx_wiki_page_save(store, ctx, slug, slug_n, 451 content, cn, 452 session_token, token_n, now_unix_s) 453 if save_rc != NX_WPS_OK { 454 if save_rc == 0 - NX_WPS_REFUSED { return 0 - NX_WV_REFUSED } 455 return 0 - NX_WV_SAVE_FAILED 456 } 457 return NX_WV_OK 458}