adds alternative non-callback API for transactions
3 files changed,  +387, -0
M README.md
+51, -0
 1@@ -181,6 +181,57 @@ input, _ := ygo.MapInput(data)
 2 m.Insert(txn, "user", input)
 3 ```
 4 
 5+### Explicit Transactions
 6+
 7+Use explicit Begin/Commit for flatter code with less nesting:
 8+
 9+```go
10+// Old: Callback-based API (nested)
11+err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
12+    txt.Insert(txn, 0, "Hello")
13+    arr.Push(txn, ygo.Int(42))
14+    return nil
15+})
16+
17+// New: Explicit transaction API (flatter)
18+txn, err := doc.BeginWrite()
19+if err != nil {
20+    return err
21+}
22+defer func() {
23+    if r := recover(); r != nil {
24+        txn.Rollback()
25+        panic(r)
26+    }
27+    if err != nil {
28+        txn.Rollback()
29+    } else {
30+        txn.Commit()
31+    }
32+}()
33+
34+// Perform operations (no nesting!)
35+txt.Insert(txn, 0, "Hello")
36+arr.Push(txn, ygo.Int(42))
37+
38+// Read transactions work similarly
39+txn, err = doc.BeginRead()
40+if err != nil {
41+    return err
42+}
43+defer txn.Commit()
44+
45+length := arr.Len()
46+content, _ := txt.String(txn)
47+```
48+
49+The explicit API gives you full control over transaction lifecycle:
50+- **BeginRead()** → **Commit()** for read-only transactions
51+- **BeginWrite()** → **Commit()** or **Rollback()** for write transactions
52+- **BeginWriteWithOrigin(origin)** to mark changes with a source identifier
53+
54+**Note on Rollback:** The y-crdt library applies changes immediately during the transaction. Calling `Rollback()` only closes the transaction without syncing to remote peers - the local document state is already modified. For true atomic operations that automatically rollback on error, use `WithWriteTransaction()` which only commits if the callback succeeds.
55+
56 ## Serialization
57 
58 Documents support standard Go binary marshaling:
A explicit_transaction_test.go
+261, -0
  1@@ -0,0 +1,261 @@
  2+package ygo_test
  3+
  4+import (
  5+	"testing"
  6+
  7+	"github.com/BTBurke/ygo"
  8+)
  9+
 10+func TestBeginRead(t *testing.T) {
 11+	doc, err := ygo.NewDoc()
 12+	if err != nil {
 13+		t.Fatalf("failed to create doc: %v", err)
 14+	}
 15+	defer doc.Destroy()
 16+
 17+	// Create a text field
 18+	txt, err := doc.GetText("content")
 19+	if err != nil {
 20+		t.Fatalf("failed to get text: %v", err)
 21+	}
 22+	defer txt.Destroy()
 23+
 24+	// Insert some content first
 25+	err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
 26+		txt.Insert(txn, 0, "Hello, World!")
 27+		return nil
 28+	})
 29+	if err != nil {
 30+		t.Fatalf("failed to insert text: %v", err)
 31+	}
 32+
 33+	// Now test explicit read transaction
 34+	txn, err := doc.BeginRead()
 35+	if err != nil {
 36+		t.Fatalf("failed to begin read transaction: %v", err)
 37+	}
 38+
 39+	// Read content
 40+	content, err := txt.String(txn)
 41+	if err != nil {
 42+		txn.Commit()
 43+		t.Fatalf("failed to get string: %v", err)
 44+	}
 45+
 46+	// Commit the transaction
 47+	txn.Commit()
 48+
 49+	if content != "Hello, World!" {
 50+		t.Errorf("expected 'Hello, World!', got '%s'", content)
 51+	}
 52+}
 53+
 54+func TestBeginWrite(t *testing.T) {
 55+	doc, err := ygo.NewDoc()
 56+	if err != nil {
 57+		t.Fatalf("failed to create doc: %v", err)
 58+	}
 59+	defer doc.Destroy()
 60+
 61+	// Create a text field
 62+	txt, err := doc.GetText("content")
 63+	if err != nil {
 64+		t.Fatalf("failed to get text: %v", err)
 65+	}
 66+	defer txt.Destroy()
 67+
 68+	// Test explicit write transaction
 69+	txn, err := doc.BeginWrite()
 70+	if err != nil {
 71+		t.Fatalf("failed to begin write transaction: %v", err)
 72+	}
 73+
 74+	// Write content
 75+	err = txt.Insert(txn, 0, "Hello from explicit transaction!")
 76+	if err != nil {
 77+		txn.Rollback()
 78+		t.Fatalf("failed to insert text: %v", err)
 79+	}
 80+
 81+	// Commit the transaction
 82+	txn.Commit()
 83+
 84+	// Verify the write was applied
 85+	var content string
 86+	err = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
 87+		var err error
 88+		content, err = txt.String(txn)
 89+		return err
 90+	})
 91+	if err != nil {
 92+		t.Fatalf("failed to read text: %v", err)
 93+	}
 94+
 95+	if content != "Hello from explicit transaction!" {
 96+		t.Errorf("expected 'Hello from explicit transaction!', got '%s'", content)
 97+	}
 98+}
 99+
100+func TestBeginWriteRollback(t *testing.T) {
101+	doc, err := ygo.NewDoc()
102+	if err != nil {
103+		t.Fatalf("failed to create doc: %v", err)
104+	}
105+	defer doc.Destroy()
106+
107+	// Create a text field
108+	txt, err := doc.GetText("content")
109+	if err != nil {
110+		t.Fatalf("failed to get text: %v", err)
111+	}
112+	defer txt.Destroy()
113+
114+	// Insert some content first
115+	err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
116+		txt.Insert(txn, 0, "Original content")
117+		return nil
118+	})
119+	if err != nil {
120+		t.Fatalf("failed to insert text: %v", err)
121+	}
122+
123+	// Test rollback
124+	txn, err := doc.BeginWrite()
125+	if err != nil {
126+		t.Fatalf("failed to begin write transaction: %v", err)
127+	}
128+
129+	// Write content
130+	err = txt.Insert(txn, 16, " - this is a test")
131+	if err != nil {
132+		txn.Rollback()
133+		t.Fatalf("failed to insert text: %v", err)
134+	}
135+
136+	// Rollback instead of commit
137+	txn.Rollback()
138+
139+	// In y-crdt, changes are applied immediately within the transaction scope.
140+	// Rollback() only closes the transaction without syncing to remote peers.
141+	// The local document state is already modified. This is a limitation of the
142+	// underlying y-crdt library.
143+	//
144+	// For true atomic operations that can be rolled back, use the callback-based
145+	// WithWriteTransaction API which only commits on success.
146+
147+	// Verify that content was modified (y-crdt behavior)
148+	var content string
149+	err = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
150+		var err error
151+		content, err = txt.String(txn)
152+		return err
153+	})
154+	if err != nil {
155+		t.Fatalf("failed to read text: %v", err)
156+	}
157+
158+	// Note: This demonstrates that BeginWrite + Rollback doesn't undo changes
159+	// Use WithWriteTransaction for atomic operations with automatic rollback
160+	if content == "Original content" {
161+		t.Log("Note: Rollback did undo changes (this may vary by y-crdt version)")
162+	} else {
163+		t.Logf("Note: Content after rollback: '%s' (changes are not undone in y-crdt)", content)
164+	}
165+}
166+
167+func TestBeginWriteWithOrigin(t *testing.T) {
168+	doc, err := ygo.NewDoc()
169+	if err != nil {
170+		t.Fatalf("failed to create doc: %v", err)
171+	}
172+	defer doc.Destroy()
173+
174+	// Create a text field
175+	txt, err := doc.GetText("content")
176+	if err != nil {
177+		t.Fatalf("failed to get text: %v", err)
178+	}
179+	defer txt.Destroy()
180+
181+	// Test explicit write transaction with origin
182+	origin := []byte("test-origin")
183+	txn, err := doc.BeginWriteWithOrigin(origin)
184+	if err != nil {
185+		t.Fatalf("failed to begin write transaction with origin: %v", err)
186+	}
187+
188+	// Write content
189+	err = txt.Insert(txn, 0, "Content with origin")
190+	if err != nil {
191+		txn.Rollback()
192+		t.Fatalf("failed to insert text: %v", err)
193+	}
194+
195+	// Commit the transaction
196+	txn.Commit()
197+
198+	// Verify the write was applied
199+	var content string
200+	err = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
201+		var err error
202+		content, err = txt.String(txn)
203+		return err
204+	})
205+	if err != nil {
206+		t.Fatalf("failed to read text: %v", err)
207+	}
208+
209+	if content != "Content with origin" {
210+		t.Errorf("expected 'Content with origin', got '%s'", content)
211+	}
212+}
213+
214+func TestBeginReadWhileWriteActive(t *testing.T) {
215+	doc, err := ygo.NewDoc()
216+	if err != nil {
217+		t.Fatalf("failed to create doc: %v", err)
218+	}
219+	defer doc.Destroy()
220+
221+	// Start a write transaction
222+	writeTxn, err := doc.BeginWrite()
223+	if err != nil {
224+		t.Fatalf("failed to begin write transaction: %v", err)
225+	}
226+
227+	// Attempt to start a read transaction while write is active
228+	// This should fail because only one transaction can be active at a time
229+	_, err = doc.BeginRead()
230+	if err == nil {
231+		writeTxn.Rollback()
232+		t.Error("expected error when starting read transaction while write transaction is active")
233+	}
234+
235+	// Clean up the write transaction
236+	writeTxn.Rollback()
237+}
238+
239+func TestBeginWriteWhileReadActive(t *testing.T) {
240+	doc, err := ygo.NewDoc()
241+	if err != nil {
242+		t.Fatalf("failed to create doc: %v", err)
243+	}
244+	defer doc.Destroy()
245+
246+	// Start a read transaction
247+	readTxn, err := doc.BeginRead()
248+	if err != nil {
249+		t.Fatalf("failed to begin read transaction: %v", err)
250+	}
251+
252+	// Attempt to start a write transaction while read is active
253+	// This should fail because only one transaction can be active at a time
254+	_, err = doc.BeginWrite()
255+	if err == nil {
256+		readTxn.Commit()
257+		t.Error("expected error when starting write transaction while read transaction is active")
258+	}
259+
260+	// Clean up the read transaction
261+	readTxn.Commit()
262+}
M transaction.go
+75, -0
 1@@ -40,6 +40,33 @@ func (d *Doc) WithReadTransaction(fn func(*Transaction) error) error {
 2 	return err
 3 }
 4 
 5+// BeginRead starts a read-only transaction.
 6+// The caller must call Commit() when done to release resources.
 7+// Returns an error if another transaction is already active.
 8+//
 9+// Example:
10+//
11+//	txn, err := doc.BeginRead()
12+//	if err != nil {
13+//	    return err
14+//	}
15+//	defer txn.Commit()
16+//
17+//	length := arr.Len()
18+//	// ... read operations ...
19+func (d *Doc) BeginRead() (*Transaction, error) {
20+	if d.ptr == nil {
21+		return nil, ErrNilDocument
22+	}
23+
24+	txn := C.ydoc_read_transaction(d.ptr)
25+	if txn == nil {
26+		return nil, fmt.Errorf("failed to create read transaction: another transaction may be active")
27+	}
28+
29+	return &Transaction{ptr: txn, doc: d}, nil
30+}
31+
32 // WithWriteTransaction executes a callback within a read-write transaction.
33 // If the callback returns nil, the transaction is committed.
34 // If the callback returns an error, the transaction is rolled back.
35@@ -110,6 +137,54 @@ func (d *Doc) WithWriteTransactionWithOrigin(origin []byte, fn func(*Transaction
36 	return nil
37 }
38 
39+// BeginWrite starts a read-write transaction.
40+// The caller must call Commit() to apply changes or Rollback() to abort.
41+// Returns an error if another transaction is already active.
42+//
43+// Example:
44+//
45+//	txn, err := doc.BeginWrite()
46+//	if err != nil {
47+//	    return err
48+//	}
49+//	defer func() {
50+//	    if err != nil {
51+//	        txn.Rollback()
52+//	    } else {
53+//	        txn.Commit()
54+//	    }
55+//	}()
56+//
57+//	txt.Insert(txn, 0, "Hello")
58+//	// ... more write operations ...
59+func (d *Doc) BeginWrite() (*Transaction, error) {
60+	return d.BeginWriteWithOrigin(nil)
61+}
62+
63+// BeginWriteWithOrigin starts a read-write transaction with an origin marker.
64+// The origin can be used by event handlers and undo managers to identify change sources.
65+// The caller must call Commit() to apply changes or Rollback() to abort.
66+// Returns an error if another transaction is already active.
67+func (d *Doc) BeginWriteWithOrigin(origin []byte) (*Transaction, error) {
68+	if d.ptr == nil {
69+		return nil, ErrNilDocument
70+	}
71+
72+	var originPtr *C.char
73+	var originLen C.uint32_t
74+	if len(origin) > 0 {
75+		originPtr = (*C.char)(unsafe.Pointer(&origin[0]))
76+		originLen = C.uint32_t(len(origin))
77+	}
78+
79+	txn := C.ydoc_write_transaction(d.ptr, originLen, originPtr)
80+	if txn == nil {
81+		return nil, fmt.Errorf("failed to create write transaction: another transaction may be active")
82+	}
83+
84+	return &Transaction{ptr: txn, doc: d}, nil
85+}
86+
87 // IsWriteable returns true if this is a read-write transaction.
88 func (t *Transaction) IsWriteable() bool {
89 	if t.ptr == nil {