document.go
1package ygo
2
3/*
4#include "libyrs.h"
5#include <stdlib.h>
6*/
7import "C"
8import (
9 "encoding"
10 "encoding/json"
11 "fmt"
12 "unsafe"
13
14 "git.kilimanjaro.io/ygo/sync"
15)
16
17// Compile-time interface compliance check
18var (
19 _ encoding.BinaryMarshaler = (*Doc)(nil)
20 _ encoding.BinaryUnmarshaler = (*Doc)(nil)
21)
22
23// MarshalOption configures Document JSON marshaling.
24type MarshalOption func(*marshalConfig)
25
26type marshalConfig struct {
27 roots []rootSpec
28}
29
30type rootSpec struct {
31 name string
32 typ RootType
33}
34
35// RootType identifies the type of shared root to marshal.
36type RootType int
37
38const (
39 RootTypeMap RootType = iota
40 RootTypeArray
41 RootTypeXmlFragment
42 RootTypeText
43)
44
45// WithMap includes the named Map in the marshaled output.
46func WithMap(name string) MarshalOption {
47 return func(cfg *marshalConfig) {
48 cfg.roots = append(cfg.roots, rootSpec{name: name, typ: RootTypeMap})
49 }
50}
51
52// WithArray includes the named Array in the marshaled output.
53func WithArray(name string) MarshalOption {
54 return func(cfg *marshalConfig) {
55 cfg.roots = append(cfg.roots, rootSpec{name: name, typ: RootTypeArray})
56 }
57}
58
59// WithXmlFragment includes the named XmlFragment in the marshaled output.
60func WithXmlFragment(name string) MarshalOption {
61 return func(cfg *marshalConfig) {
62 cfg.roots = append(cfg.roots, rootSpec{name: name, typ: RootTypeXmlFragment})
63 }
64}
65
66// WithText includes the named Text in the marshaled output.
67func WithText(name string) MarshalOption {
68 return func(cfg *marshalConfig) {
69 cfg.roots = append(cfg.roots, rootSpec{name: name, typ: RootTypeText})
70 }
71}
72
73// Doc represents a Yjs document - the core unit of collaborative resources.
74// All shared collections live within a document scope.
75type Doc struct {
76 ptr *C.YDoc
77 sync *sync.SyncClient
78}
79
80// NewDoc creates a new document with a randomized client ID.
81func NewDoc() (*Doc, error) {
82 d := &Doc{ptr: C.ydoc_new()}
83 if d.ptr == nil {
84 return nil, fmt.Errorf("failed to create document: C.ydoc_new() returned nil")
85 }
86 return d, nil
87}
88
89// NewDocWithOptions creates a new document with specific options.
90func NewDocWithOptions(opts DocOptions) (*Doc, error) {
91 cOpts := opts.toC()
92 d := &Doc{ptr: C.ydoc_new_with_options(cOpts)}
93
94 // Free allocated C strings
95 if cOpts.guid != nil {
96 C.free(unsafe.Pointer(cOpts.guid))
97 }
98 if cOpts.collection_id != nil {
99 C.free(unsafe.Pointer(cOpts.collection_id))
100 }
101
102 if d.ptr == nil {
103 return nil, fmt.Errorf("failed to create document with options: C.ydoc_new_with_options() returned nil")
104 }
105
106 return d, nil
107}
108
109// Clone creates a shallow, reference-counted clone of the document.
110// Both the original and clone share the same underlying data - changes to one
111// are immediately visible to the other. Use DeepClone() if you need an
112// independent copy with no shared state.
113func (d *Doc) Clone() (*Doc, error) {
114 if d.ptr == nil {
115 return nil, ErrNilDocument
116 }
117 cloned := &Doc{ptr: C.ydoc_clone(d.ptr)}
118 if cloned.ptr == nil {
119 return nil, fmt.Errorf("failed to clone document: C.ydoc_clone() returned nil")
120 }
121 return cloned, nil
122}
123
124// DeepClone creates an independent copy of the document by serializing its
125// current state and deserializing into a new document. The returned document
126// has no shared state with the original - changes to one do not affect the
127// other. This is useful for creating checkpoints, backups, or isolated copies
128// that can be modified independently.
129func (d *Doc) DeepClone() (*Doc, error) {
130 data, err := d.MarshalBinary()
131 if err != nil {
132 return nil, fmt.Errorf("failed to marshal document: %w", err)
133 }
134
135 clone, err := NewDoc()
136 if err != nil {
137 return nil, fmt.Errorf("failed to create new document: %w", err)
138 }
139
140 if err := clone.UnmarshalBinary(data); err != nil {
141 clone.Destroy()
142 return nil, fmt.Errorf("failed to unmarshal document: %w", err)
143 }
144
145 return clone, nil
146}
147
148// MarshalBinary implements encoding.BinaryMarshaler.
149// Returns the document state as V1-encoded binary data.
150// This enables idiomatic usage with Go's encoding packages.
151func (d *Doc) MarshalBinary() ([]byte, error) {
152 if d == nil || d.ptr == nil {
153 return nil, ErrNilDocument
154 }
155
156 var data []byte
157 err := d.WithReadTransaction(func(txn *Transaction) error {
158 update := txn.GetStateDiff(nil)
159 if update == nil {
160 return fmt.Errorf("failed to get document state")
161 }
162 data = update.Data()
163 return nil
164 })
165
166 if err != nil {
167 return nil, fmt.Errorf("failed to marshal document: %w", err)
168 }
169
170 return data, nil
171}
172
173// UnmarshalBinary implements encoding.BinaryUnmarshaler.
174// Applies V1-encoded binary data to the document.
175// This enables idiomatic usage with Go's encoding packages.
176func (d *Doc) UnmarshalBinary(data []byte) error {
177 if d == nil || d.ptr == nil {
178 return ErrNilDocument
179 }
180
181 if len(data) == 0 {
182 return nil // Nothing to apply
183 }
184
185 update := UpdateFromBytes(data)
186 if update == nil {
187 return fmt.Errorf("failed to create update from data")
188 }
189
190 return d.WithWriteTransaction(func(txn *Transaction) error {
191 return txn.ApplyUpdate(update)
192 })
193}
194
195// ClientID returns the unique client identifier.
196func (d *Doc) ClientID() uint64 {
197 if d.ptr == nil {
198 return 0
199 }
200 return uint64(C.ydoc_id(d.ptr))
201}
202
203// GUID returns the document's globally unique identifier.
204func (d *Doc) GUID() string {
205 if d.ptr == nil {
206 return ""
207 }
208 return cStringToGoAndFree(C.ydoc_guid(d.ptr))
209}
210
211// CollectionID returns the collection identifier or empty string if none.
212func (d *Doc) CollectionID() string {
213 if d.ptr == nil {
214 return ""
215 }
216 cStr := C.ydoc_collection_id(d.ptr)
217 if cStr == nil {
218 return ""
219 }
220 return cStringToGoAndFree(cStr)
221}
222
223// ShouldLoad returns whether the document requests a data load.
224func (d *Doc) ShouldLoad() bool {
225 if d.ptr == nil {
226 return false
227 }
228 return C.ydoc_should_load(d.ptr) != 0
229}
230
231// AutoLoad returns whether subdocuments are auto-loaded.
232func (d *Doc) AutoLoad() bool {
233 if d.ptr == nil {
234 return false
235 }
236 return C.ydoc_auto_load(d.ptr) != 0
237}
238
239// Destroy releases all memory allocated by the document.
240// If a sync client is attached, it will send any pending updates,
241// gracefully disconnect from the server, then destroy the document.
242// Safe to call from within the OnUpdate callback.
243// Callers must call Destroy() explicitly when done with the document.
244func (d *Doc) Destroy() {
245 // Close sync connection first (sends pending updates)
246 if d.sync != nil {
247 d.sync.SendPendingAndClose()
248 d.sync = nil
249 }
250
251 if d.ptr != nil {
252 C.ydoc_destroy(d.ptr)
253 d.ptr = nil
254 }
255}
256
257// MarshalJSON implements json.Marshaler for Document.
258// Returns the specified top-level shared types as JSON.
259// If no options are provided, returns an empty JSON object "{}".
260//
261// Example:
262//
263// doc.MarshalJSON(ygo.WithMap("users"), ygo.WithArray("items"))
264// // Returns: {"users": {...}, "items": [...]}
265func (d *Doc) MarshalJSON(opts ...MarshalOption) ([]byte, error) {
266 if d.ptr == nil {
267 return nil, ErrNilDocument
268 }
269
270 cfg := &marshalConfig{}
271 for _, opt := range opts {
272 opt(cfg)
273 }
274
275 if len(cfg.roots) == 0 {
276 return []byte("{}"), nil
277 }
278
279 roots := make(map[string]struct {
280 typ RootType
281 m *Map
282 a *Array
283 f *XmlFragment
284 t *Text
285 }, len(cfg.roots))
286
287 for _, spec := range cfg.roots {
288 switch spec.typ {
289 case RootTypeMap:
290 m, err := d.GetMap(spec.name)
291 if err != nil {
292 return nil, fmt.Errorf("map %q: %w", spec.name, err)
293 }
294 roots[spec.name] = struct {
295 typ RootType
296 m *Map
297 a *Array
298 f *XmlFragment
299 t *Text
300 }{typ: spec.typ, m: m}
301
302 case RootTypeArray:
303 a, err := d.GetArray(spec.name)
304 if err != nil {
305 return nil, fmt.Errorf("array %q: %w", spec.name, err)
306 }
307 roots[spec.name] = struct {
308 typ RootType
309 m *Map
310 a *Array
311 f *XmlFragment
312 t *Text
313 }{typ: spec.typ, a: a}
314
315 case RootTypeXmlFragment:
316 f, err := d.GetXmlFragment(spec.name)
317 if err != nil {
318 return nil, fmt.Errorf("xml fragment %q: %w", spec.name, err)
319 }
320 roots[spec.name] = struct {
321 typ RootType
322 m *Map
323 a *Array
324 f *XmlFragment
325 t *Text
326 }{typ: spec.typ, f: f}
327
328 case RootTypeText:
329 t, err := d.GetText(spec.name)
330 if err != nil {
331 return nil, fmt.Errorf("text %q: %w", spec.name, err)
332 }
333 roots[spec.name] = struct {
334 typ RootType
335 m *Map
336 a *Array
337 f *XmlFragment
338 t *Text
339 }{typ: spec.typ, t: t}
340 }
341 }
342
343 defer func() {
344 for _, r := range roots {
345 if r.m != nil {
346 r.m.Destroy()
347 }
348 if r.a != nil {
349 r.a.Destroy()
350 }
351 if r.f != nil {
352 r.f.Destroy()
353 }
354 if r.t != nil {
355 r.t.Destroy()
356 }
357 }
358 }()
359
360 result := make(map[string]interface{})
361 err := d.WithReadTransaction(func(txn *Transaction) error {
362 for name, r := range roots {
363 var jsonBytes []byte
364 var err error
365
366 switch r.typ {
367 case RootTypeMap:
368 jsonBytes, err = r.m.MarshalJSONWithTxn(txn)
369 case RootTypeArray:
370 jsonBytes, err = r.a.MarshalJSONWithTxn(txn)
371 case RootTypeXmlFragment:
372 jsonBytes, err = r.f.MarshalJSONWithTxn(txn)
373 case RootTypeText:
374 jsonBytes, err = r.t.MarshalJSONWithTxn(txn)
375 }
376
377 if err != nil {
378 return fmt.Errorf("%s: %w", name, err)
379 }
380
381 var val interface{}
382 if err := json.Unmarshal(jsonBytes, &val); err != nil {
383 return fmt.Errorf("%s: %w", name, err)
384 }
385 result[name] = val
386 }
387 return nil
388 })
389
390 if err != nil {
391 return nil, err
392 }
393
394 return json.Marshal(result)
395}