Skip to content

Commit e6b2112

Browse files
fix(zettel_id_log): emit single hyphence header instead of stacked docs
Closes #212. `AppendEntry` previously wrapped every entry in a fresh `hyphence.TypedBlob` and emitted `Coder.EncodeTo` for it, so every append re-wrote the `---\n! zettel_id_log-v1\n---\n` preamble. The file ended up as stacked hyphence docs: 2 yin/yang entries from init -> 2 type headers; N more appends -> N more headers. The on-disk shape now mirrors the sibling `inventory_lists_log`: --- ! zettel_id_log-v1 --- side = "yin" tai = "..." markl-id = "..." word-count = 6 side = "yang" tai = "..." markl-id = "..." word-count = 6 A single header at the top of the file, then bodies appended below with blank-line separation. ## Writer `AppendEntry` stats the file after the `O_CREATE|O_APPEND|O_WRONLY` open. Size 0 means a fresh file: emit the header preamble then the body. Size > 0 means an existing log: emit a `\n` separator then the body only. Callers (`genesis.go`, `add_zettel_ids.go`, `migrate_zettel_ids.go`) don't need to know about the header --- the first append into a fresh file lays it down. The type-header line is `! <type-without-prefix>`. The `TypeZettelIdLogVCurrent` constant carries the leading `!` (e.g. `!zettel_id_log-v1`), so we `strings.TrimPrefix` to avoid emitting `! !zettel_id_log-v1`. `encodeEntryBody` calls `charlie_zil.V1Document.Encode` directly to get the TOML key/value bytes without any surrounding hyphence framing. Replaces the old per-entry `Coder.EncodeTo(&hyphence.TypedBlob)` that pulled in the full header. ## Reader `ReadAllEntries` keeps back-compat with the v14 / v15 stacked-doc fixtures via a new line-driven `segmentBodies` helper: - A `---` line toggles an `inHeader` state. - Lines while `inHeader` (e.g. `! zettel_id_log-v1`) are discarded. - Non-empty non-header lines accumulate into the current body. - A blank line flushes the current body and starts the next. - Opening a new header also flushes (so stacked-doc entries split at the inter-entry preamble). Both shapes parse to the same body sequence with no per-shape branch. ## Verification New BATS file `zz-tests_bats/current_version/zettel_id_log_format.bats` asserts the on-disk contract: zettel_id_log_has_single_header_after_init # 1 header after init zettel_id_log_has_single_header_after_add_zettel_ids # 1 header after init + add inventory_lists_log_has_single_header # pins the working sibling All three pass. The existing `add_zettel_ids.bats` (5 tests) and `migrate_zettel_ids.bats` (2 tests) also pass --- the new dual-shape reader handles both fresh single-header logs and v14/v15 stacked-doc fixtures. :clown:
1 parent 7740860 commit e6b2112

2 files changed

Lines changed: 278 additions & 37 deletions

File tree

go/internal/delta/zettel_id_log/log.go

Lines changed: 139 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package zettel_id_log
22

33
import (
44
"bufio"
5+
"fmt"
6+
"io"
57
"os"
68
"strings"
79

10+
charlie_zil "code.linenisgreat.com/dodder/go/internal/charlie/zettel_id_log"
811
"code.linenisgreat.com/dodder/go/internal/bravo/ids"
912
"code.linenisgreat.com/dodder/go/lib/alfa/ohio"
1013
"github.com/amarbel-llc/madder/go/pkgs/hyphence"
11-
"github.com/amarbel-llc/purse-first/libs/dewey/alfa/pool"
1214
"github.com/amarbel-llc/purse-first/libs/dewey/bravo/errors"
1315
"github.com/amarbel-llc/purse-first/libs/dewey/delta/files"
1416
)
@@ -17,6 +19,16 @@ type Log struct {
1719
Path string
1820
}
1921

22+
// AppendEntry appends `entry` to the log on disk. The first call against
23+
// an empty (or nonexistent) file writes a single hyphence header
24+
// (`---\n! zettel_id_log-vN\n---\n`) followed by the entry body.
25+
// Subsequent calls append just a body, separated from prior content by a
26+
// blank line.
27+
//
28+
// Background: an earlier implementation wrapped every entry in a fresh
29+
// hyphence TypedBlob and emitted the full header per call, producing
30+
// stacked hyphence docs on disk (amarbel-llc/dodder#212). The reader
31+
// here keeps back-compat for those legacy files.
2032
func (l Log) AppendEntry(entry Entry) (err error) {
2133
var file *os.File
2234

@@ -31,19 +43,82 @@ func (l Log) AppendEntry(entry Entry) (err error) {
3143

3244
defer errors.DeferredCloser(&err, file)
3345

34-
typedBlob := &hyphence.TypedBlob[Entry]{
35-
Type: ids.GetOrPanic(ids.TypeZettelIdLogVCurrent).TypeStruct.ToMadder(),
36-
Blob: entry,
46+
var stat os.FileInfo
47+
48+
if stat, err = file.Stat(); err != nil {
49+
err = errors.Wrap(err)
50+
return err
51+
}
52+
53+
if stat.Size() == 0 {
54+
// The type constant already carries a leading `!`
55+
// (e.g. `!zettel_id_log-v1`). Hyphence's type-header line is
56+
// `! <type-without-prefix>`, so strip the prefix before
57+
// printing — otherwise we'd emit `! !zettel_id_log-v1`.
58+
typeName := strings.TrimPrefix(
59+
ids.TypeZettelIdLogVCurrent,
60+
"!",
61+
)
62+
63+
if _, err = fmt.Fprintf(
64+
file,
65+
"%s\n! %s\n%s\n\n",
66+
hyphence.Boundary,
67+
typeName,
68+
hyphence.Boundary,
69+
); err != nil {
70+
err = errors.Wrap(err)
71+
return err
72+
}
73+
} else {
74+
// Blank-line separator between bodies.
75+
if _, err = io.WriteString(file, "\n"); err != nil {
76+
err = errors.Wrap(err)
77+
return err
78+
}
3779
}
3880

39-
if _, err = Coder.EncodeTo(typedBlob, file); err != nil {
81+
var body []byte
82+
83+
if body, err = encodeEntryBody(entry); err != nil {
84+
err = errors.Wrap(err)
85+
return err
86+
}
87+
88+
if _, err = file.Write(body); err != nil {
4089
err = errors.Wrap(err)
4190
return err
4291
}
4392

4493
return err
4594
}
4695

96+
// encodeEntryBody returns the body-only encoding of an entry — the TOML
97+
// key/value block, with no surrounding hyphence boundaries or type
98+
// header. Mirrors what `charlie_zil.V1Document.Encode` returns directly.
99+
func encodeEntryBody(entry Entry) ([]byte, error) {
100+
doc, err := charlie_zil.DecodeV1(nil)
101+
if err != nil {
102+
return nil, errors.Wrap(err)
103+
}
104+
105+
switch v := entry.(type) {
106+
case *V1:
107+
*doc.Data() = *v
108+
case V1:
109+
*doc.Data() = v
110+
default:
111+
return nil, errors.Errorf("unsupported entry type %T", entry)
112+
}
113+
114+
body, err := doc.Encode()
115+
if err != nil {
116+
return nil, errors.Wrap(err)
117+
}
118+
119+
return body, nil
120+
}
121+
47122
func (l Log) ReadAllEntries() (entries []Entry, err error) {
48123
var file *os.File
49124

@@ -59,64 +134,91 @@ func (l Log) ReadAllEntries() (entries []Entry, err error) {
59134

60135
defer errors.DeferredCloser(&err, file)
61136

62-
bufferedReader, repoolBufferedReader := pool.GetBufferedReader(file)
63-
defer repoolBufferedReader()
137+
bufferedReader := bufio.NewReader(file)
64138

65-
segments, err := segmentEntries(bufferedReader)
139+
bodies, err := segmentBodies(bufferedReader)
66140
if err != nil {
67141
err = errors.Wrap(err)
68142
return entries, err
69143
}
70144

71-
for _, segment := range segments {
72-
var typedBlob hyphence.TypedBlob[Entry]
73-
74-
stringReader, repoolStringReader := pool.GetStringReader(segment)
75-
defer repoolStringReader()
76-
77-
if _, err = Coder.DecodeFrom(
78-
&typedBlob,
79-
stringReader,
80-
); err != nil {
81-
err = errors.Wrap(err)
82-
return entries, err
145+
for _, body := range bodies {
146+
doc, err := charlie_zil.DecodeV1([]byte(body))
147+
if err != nil {
148+
return entries, errors.Wrap(err)
83149
}
84150

85-
entries = append(entries, typedBlob.Blob)
151+
v := *doc.Data()
152+
entries = append(entries, v)
86153
}
87154

88155
return entries, err
89156
}
90157

91-
func segmentEntries(
92-
reader *bufio.Reader,
93-
) (segments []string, err error) {
158+
// segmentBodies reads a zettel_id_log file and returns the entry bodies
159+
// (TOML key/value blocks) in order, ignoring any hyphence headers.
160+
//
161+
// Handles both the current single-header shape and the legacy stacked-
162+
// doc shape (amarbel-llc/dodder#212):
163+
//
164+
// - Current shape: file starts with `---\n! zettel_id_log-v1\n---\n`,
165+
// then bodies separated by blank lines.
166+
// - Legacy shape: each body is preceded by its own `---\n! type\n---\n`
167+
// preamble.
168+
//
169+
// In both cases, this function strips header preambles and groups
170+
// non-blank, non-header lines into bodies.
171+
func segmentBodies(reader *bufio.Reader) (bodies []string, err error) {
94172
var current strings.Builder
95-
boundaryCount := 0
173+
174+
flush := func() {
175+
if current.Len() == 0 {
176+
return
177+
}
178+
bodies = append(bodies, current.String())
179+
current.Reset()
180+
}
181+
182+
// State machine: skip header preambles (`---`...`---`) and collect
183+
// body lines until a blank line or the next header.
184+
inHeader := false
96185

97186
for line, errIter := range ohio.MakeLineSeqFromReader(reader) {
98187
if errIter != nil {
99188
err = errIter
100-
return segments, err
189+
return bodies, err
101190
}
102191

103-
trimmed := strings.TrimSuffix(line, "\n")
192+
trimmedRight := strings.TrimSuffix(line, "\n")
193+
194+
if trimmedRight == hyphence.Boundary {
195+
if inHeader {
196+
// Closing boundary of a header.
197+
inHeader = false
198+
} else {
199+
// Opening boundary of a header — finalize any
200+
// previous body first.
201+
flush()
202+
inHeader = true
203+
}
204+
continue
205+
}
104206

105-
if trimmed == hyphence.Boundary {
106-
boundaryCount++
207+
if inHeader {
208+
// Header content (e.g. `! zettel_id_log-v1`); ignore.
209+
continue
210+
}
107211

108-
if boundaryCount > 2 && boundaryCount%2 == 1 {
109-
segments = append(segments, current.String())
110-
current.Reset()
111-
}
212+
if trimmedRight == "" {
213+
// Blank line — body separator in the new shape.
214+
flush()
215+
continue
112216
}
113217

114218
current.WriteString(line)
115219
}
116220

117-
if current.Len() > 0 {
118-
segments = append(segments, current.String())
119-
}
221+
flush()
120222

121-
return segments, err
223+
return bodies, err
122224
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#! /usr/bin/env bats
2+
3+
setup() {
4+
load "$(dirname "$BATS_TEST_FILE")/../lib/common.bash"
5+
6+
# for shellcheck SC2154
7+
export output
8+
}
9+
10+
teardown() {
11+
chflags_nouchg
12+
}
13+
14+
# bats file_tags=user_story:zettel_ids,format
15+
16+
# The zettel_id_log is an append-only log of provider word-list mutations
17+
# (yin / yang inits and add-zettel-ids-* operations). The on-disk shape
18+
# should mirror the existing inventory_lists_log: exactly one hyphence
19+
# header at the top of the file (`---\n! <type>\n---\n`), then a blank
20+
# line, then one entry per line (or per record) appended below.
21+
#
22+
# The bug today is that go/internal/delta/zettel_id_log/log.go's
23+
# AppendEntry wraps each entry in a fresh hyphence.TypedBlob and emits
24+
# `Coder.EncodeTo` for it, so every append re-writes the type header.
25+
# The resulting file is stacked hyphence docs, not header + body. The
26+
# reader (segmentEntries in the same file) compensates by detecting
27+
# every odd boundary as a new entry, but the wire shape is wrong.
28+
#
29+
# These tests pin the *desired* shape: exactly one `! zettel_id_log-*`
30+
# line in the whole file regardless of entry count. They fail today and
31+
# pass when the writer is reshaped to "emit header on file creation,
32+
# then append bodies only" (or equivalent).
33+
#
34+
# Companion test: inventory_lists_log_has_single_header is the
35+
# inverse — it pins the working contract of the inventory log so we
36+
# notice if it ever drifts to the broken shape.
37+
38+
function zettel_id_log_path {
39+
echo "$PWD/.dodder/local/share/zettel_id_log"
40+
}
41+
42+
function inventory_lists_log_path {
43+
echo "$PWD/.dodder/local/share/inventory_lists_log"
44+
}
45+
46+
function zettel_id_log_has_single_header_after_init { # @test
47+
wd="$(mktemp -d)"
48+
cd "$wd" || exit 1
49+
50+
run_dodder_init_disable_age
51+
52+
path="$(zettel_id_log_path)"
53+
if [[ ! -f $path ]]; then
54+
fail <<-EOM
55+
expected zettel_id_log at $path
56+
57+
directory listing under .dodder/local/share/:
58+
$(find .dodder/local/share -type f 2>&1 | sort)
59+
EOM
60+
fi
61+
62+
# Init writes a yin entry and a yang entry. The broken writer emits
63+
# two full hyphence frames (two type headers). The desired shape has
64+
# exactly one type header at the top of the file.
65+
local header_count
66+
# `grep -c` exits nonzero on zero matches; tolerate that so we can
67+
# fail with a useful diagnostic instead of bailing here under set -e.
68+
header_count="$(grep -c '^! zettel_id_log' "$path" || true)"
69+
70+
if [[ $header_count -ne 1 ]]; then
71+
fail <<-EOM
72+
zettel_id_log should have exactly 1 type-header line; got $header_count
73+
path: $path
74+
full content:
75+
$(cat "$path")
76+
EOM
77+
fi
78+
}
79+
80+
function zettel_id_log_has_single_header_after_add_zettel_ids { # @test
81+
wd="$(mktemp -d)"
82+
cd "$wd" || exit 1
83+
84+
run_dodder_init_disable_age
85+
86+
# Append a third entry so the bug (re-emitted header per append) is
87+
# extra obvious: 3 entries -> 3 headers under the broken writer.
88+
run bash -c 'echo -e "alpha\nbravo" | '"$DODDER_BIN"' add-zettel-ids-yin'
89+
assert_success
90+
91+
path="$(zettel_id_log_path)"
92+
local header_count
93+
# `grep -c` exits nonzero on zero matches; tolerate that so we can
94+
# fail with a useful diagnostic instead of bailing here under set -e.
95+
header_count="$(grep -c '^! zettel_id_log' "$path" || true)"
96+
97+
if [[ $header_count -ne 1 ]]; then
98+
fail <<-EOM
99+
zettel_id_log should have exactly 1 type-header line after init + add-zettel-ids-yin; got $header_count
100+
path: $path
101+
full content:
102+
$(cat "$path")
103+
EOM
104+
fi
105+
}
106+
107+
function inventory_lists_log_has_single_header { # @test
108+
# Inverse of the zettel_id_log tests: pin the inventory log's
109+
# already-correct shape so we notice if it ever regresses to the
110+
# stacked-doc format.
111+
wd="$(mktemp -d)"
112+
cd "$wd" || exit 1
113+
114+
run_dodder_init_disable_age
115+
116+
create_test_zettels
117+
118+
path="$(inventory_lists_log_path)"
119+
if [[ ! -f $path ]]; then
120+
fail <<-EOM
121+
expected inventory_lists_log at $path
122+
123+
directory listing under .dodder/local/share/:
124+
$(find .dodder/local/share -type f 2>&1 | sort)
125+
EOM
126+
fi
127+
128+
local header_count
129+
header_count="$(grep -c '^! inventory_list' "$path")"
130+
131+
if [[ $header_count -ne 1 ]]; then
132+
fail <<-EOM
133+
inventory_lists_log should have exactly 1 type-header line; got $header_count
134+
path: $path
135+
full content:
136+
$(cat "$path")
137+
EOM
138+
fi
139+
}

0 commit comments

Comments
 (0)