tangled
alpha
login
or
join now
atscan.net
/
plcbundle-go
1
fork
atom
[DEPRECATED] Go implementation of plcbundle
1
fork
atom
overview
issues
pulls
pipelines
better docs
tree.fail
4 months ago
146a2963
4c6ec7f4
+2548
-596
7 changed files
expand all
collapse all
unified
split
.gitignore
README.md
docs
cli.md
library.md
security.md
specification.md
plc_bundles.json
+2
-1
.gitignore
···
1
1
.DS_Store
2
2
-
/plcbundle
2
2
+
/plcbundle
3
3
+
plc_bundles.json
+58
-588
README.md
···
27
27
```
28
28
29
29
> ⚠️ **Preview Version - Do Not Use in Production!**
30
30
-
>
31
31
-
> This project and plcbundle specification is currently unstable and under heavy development. Things can break at any time. Bundle hashes or data formats may change. **Do not** use this for production systems. Please wait for the **`1.0`** release.
32
30
33
31
plcbundle archives AT Protocol's [DID PLC Directory](https://plc.directory/) operations into immutable, cryptographically-chained bundles of 10,000 operations. Each bundle is hashed (SHA-256), compressed (zstd), and linked to the previous bundle, creating a verifiable chain of DID operations.
34
32
35
35
-
This repository contains a reference library and a CLI tool written in Go language.
33
33
+
* 📄 [Technical Specification](./docs/specification.md)
34
34
+
* 📚 [Library Documentation](./docs/library.md)
35
35
+
* 💻 [CLI Guide](./docs/cli.md)
36
36
+
* 📰 [Announcement Article](https://leaflet.pub/feb982b4-64cb-4549-9d25-d7e68cecb11a)
36
37
37
37
-
The technical specification for the plcbundle V1 format, index, and creation process can be found in the [specification document](./SPECIFICATION.md).
38
38
+
## What is `plcbundle`?
38
39
39
39
-
* [Article "Introducing plcbundle: A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory"](https://leaflet.pub/feb982b4-64cb-4549-9d25-d7e68cecb11a)
40
40
-
* [Reference implementations in TypeScript, Python, Ruby](https://tangled.org/@atscan.net/plcbundle-js/blob/main/plcbundle.ts)
40
40
+
plcbundle solves the problem of synchronizing and archiving PLC directory operations by:
41
41
42
42
-
## Features
42
42
+
- **Bundling**: Groups 10,000 operations into compressed, immutable files
43
43
+
- **Chaining**: Each bundle is cryptographically linked to the previous one
44
44
+
- **Verifiable**: SHA-256 hashes ensure data integrity throughout the chain
45
45
+
- **Efficient**: Zstandard compression with ~5x compression ratios
43
46
44
44
-
- 📦 **Bundle Management**: Automatically organize PLC operations into compressed bundles (10,000 operations each)
45
45
-
- 🔄 **Transparent Sync**: Fetch and cache PLC operations with automatic deduplication
46
46
-
- 🗜️ **Efficient Storage**: Zstandard compression with configurable levels
47
47
-
- ✅ **Integrity**: SHA-256 hash verification and blockchain-like chain validation
48
48
-
- 🔍 **Indexing**: Fast bundle lookup and gap detection
49
49
-
- 📊 **Export**: Query operations by time range
47
47
+
## Quick Start
50
48
51
51
-
## Installation
52
52
-
53
53
-
```bash
54
54
-
go get tangled.org/atscan.net/plcbundle
55
55
-
```
56
56
-
57
57
-
For the CLI tool:
58
58
-
59
59
-
```bash
60
60
-
go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
61
61
-
```
62
62
-
63
63
-
## Quick Start (Library)
49
49
+
### As a Library
64
50
65
51
```go
66
66
-
package main
52
52
+
import plcbundle "tangled.org/atscan.net/plcbundle"
67
53
68
68
-
import (
69
69
-
"context"
70
70
-
"log"
71
71
-
"time"
72
72
-
73
73
-
plcbundle "tangled.org/atscan.net/plcbundle"
74
74
-
)
75
75
-
76
76
-
func main() {
77
77
-
// Create a bundle manager
78
78
-
mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
79
79
-
if err != nil {
80
80
-
log.Fatal(err)
81
81
-
}
82
82
-
defer mgr.Close()
83
83
-
84
84
-
// Fetch latest bundles
85
85
-
ctx := context.Background()
86
86
-
bundle, err := mgr.FetchNext(ctx)
87
87
-
if err != nil {
88
88
-
log.Fatal(err)
89
89
-
}
90
90
-
91
91
-
log.Printf("Fetched bundle %d with %d operations",
92
92
-
bundle.BundleNumber, len(bundle.Operations))
93
93
-
}
94
94
-
```
95
95
-
96
96
-
## Library Usage
97
97
-
98
98
-
### 1. Basic Setup
99
99
-
100
100
-
```go
101
101
-
import (
102
102
-
"context"
103
103
-
"plcbundle tangled.org/atscan.net/plcbundle"
104
104
-
)
105
105
-
106
106
-
// Create manager with defaults
107
107
-
mgr, err := plcbundle.New("./bundles", "https://plc.directory")
108
108
-
if err != nil {
109
109
-
log.Fatal(err)
110
110
-
}
54
54
+
mgr, _ := plcbundle.New("./plc_data", "https://plc.directory")
111
55
defer mgr.Close()
112
112
-
```
113
113
-
114
114
-
### 2. Custom Configuration
115
56
116
116
-
```go
117
117
-
import (
118
118
-
"tangled.org/atscan.net/plcbundle/bundle"
119
119
-
"tangled.org/atscan.net/plcbundle/plc"
120
120
-
)
121
121
-
122
122
-
// Custom config
123
123
-
config := &bundle.Config{
124
124
-
BundleDir: "./my_bundles",
125
125
-
CompressionLevel: bundle.CompressionBest,
126
126
-
VerifyOnLoad: true,
127
127
-
Logger: myCustomLogger,
128
128
-
}
129
129
-
130
130
-
// Custom PLC client with rate limiting
131
131
-
plcClient := plc.NewClient("https://plc.directory",
132
132
-
plc.WithRateLimit(60, time.Minute), // 60 req/min
133
133
-
plc.WithTimeout(30*time.Second),
134
134
-
)
135
135
-
136
136
-
mgr, err := bundle.NewManager(config, plcClient)
137
137
-
```
138
138
-
139
139
-
### 3. Transparent Synchronization (Main Use Case)
140
140
-
141
141
-
This is the primary pattern for keeping your local PLC mirror up-to-date:
142
142
-
143
143
-
```go
144
144
-
package main
145
145
-
146
146
-
import (
147
147
-
"context"
148
148
-
"log"
149
149
-
"time"
150
150
-
151
151
-
plcbundle "tangled.org/atscan.net/plcbundle"
152
152
-
)
153
153
-
154
154
-
type PLCSync struct {
155
155
-
mgr *plcbundle.BundleManager
156
156
-
ctx context.Context
157
157
-
cancel context.CancelFunc
158
158
-
}
159
159
-
160
160
-
func NewPLCSync(bundleDir string) (*PLCSync, error) {
161
161
-
mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
162
162
-
if err != nil {
163
163
-
return nil, err
164
164
-
}
165
165
-
166
166
-
ctx, cancel := context.WithCancel(context.Background())
167
167
-
168
168
-
sync := &PLCSync{
169
169
-
mgr: mgr,
170
170
-
ctx: ctx,
171
171
-
cancel: cancel,
172
172
-
}
173
173
-
174
174
-
return sync, nil
175
175
-
}
176
176
-
177
177
-
func (s *PLCSync) Start(interval time.Duration) {
178
178
-
ticker := time.NewTicker(interval)
179
179
-
defer ticker.Stop()
180
180
-
181
181
-
log.Println("Starting PLC synchronization...")
182
182
-
183
183
-
for {
184
184
-
select {
185
185
-
case <-ticker.C:
186
186
-
if err := s.Update(); err != nil {
187
187
-
log.Printf("Update error: %v", err)
188
188
-
}
189
189
-
case <-s.ctx.Done():
190
190
-
return
191
191
-
}
192
192
-
}
193
193
-
}
194
194
-
195
195
-
func (s *PLCSync) Update() error {
196
196
-
log.Println("Checking for new bundles...")
197
197
-
198
198
-
for {
199
199
-
bundle, err := s.mgr.FetchNext(s.ctx)
200
200
-
if err != nil {
201
201
-
// Check if we're caught up
202
202
-
if isEndOfData(err) {
203
203
-
log.Println("✓ Up to date!")
204
204
-
return nil
205
205
-
}
206
206
-
return err
207
207
-
}
208
208
-
209
209
-
log.Printf("✓ Fetched bundle %06d (%d ops, %d DIDs)",
210
210
-
bundle.BundleNumber,
211
211
-
len(bundle.Operations),
212
212
-
bundle.DIDCount)
213
213
-
}
214
214
-
}
215
215
-
216
216
-
func (s *PLCSync) Stop() {
217
217
-
s.cancel()
218
218
-
s.mgr.Close()
219
219
-
}
220
220
-
221
221
-
func isEndOfData(err error) bool {
222
222
-
return err != nil &&
223
223
-
(strings.Contains(err.Error(), "insufficient operations") ||
224
224
-
strings.Contains(err.Error(), "caught up"))
225
225
-
}
226
226
-
227
227
-
// Usage
228
228
-
func main() {
229
229
-
sync, err := NewPLCSync("./plc_bundles")
230
230
-
if err != nil {
231
231
-
log.Fatal(err)
232
232
-
}
233
233
-
defer sync.Stop()
234
234
-
235
235
-
// Update every 5 minutes
236
236
-
sync.Start(5 * time.Minute)
237
237
-
}
238
238
-
```
239
239
-
240
240
-
### 4. Getting Bundles
241
241
-
242
242
-
```go
243
243
-
ctx := context.Background()
244
244
-
245
245
-
// Get all bundles
246
246
-
index := mgr.GetIndex()
247
247
-
bundles := index.GetBundles()
248
248
-
249
249
-
for _, meta := range bundles {
250
250
-
log.Printf("Bundle %06d: %d ops, %s to %s",
251
251
-
meta.BundleNumber,
252
252
-
meta.OperationCount,
253
253
-
meta.StartTime.Format(time.RFC3339),
254
254
-
meta.EndTime.Format(time.RFC3339))
255
255
-
}
256
256
-
257
257
-
// Load specific bundle
258
258
-
bundle, err := mgr.Load(ctx, 1)
259
259
-
if err != nil {
260
260
-
log.Fatal(err)
261
261
-
}
262
262
-
263
263
-
log.Printf("Loaded %d operations", len(bundle.Operations))
264
264
-
```
265
265
-
266
266
-
### 5. Getting Operations from Bundles
267
267
-
268
268
-
```go
269
269
-
// Load a bundle and iterate operations
270
270
-
bundle, err := mgr.Load(ctx, 1)
271
271
-
if err != nil {
272
272
-
log.Fatal(err)
273
273
-
}
274
274
-
275
275
-
for _, op := range bundle.Operations {
276
276
-
log.Printf("DID: %s, CID: %s, Time: %s",
277
277
-
op.DID,
278
278
-
op.CID,
279
279
-
op.CreatedAt.Format(time.RFC3339))
280
280
-
281
281
-
// Access operation data
282
282
-
if opType, ok := op.Operation["type"].(string); ok {
283
283
-
log.Printf(" Type: %s", opType)
284
284
-
}
285
285
-
}
286
286
-
```
287
287
-
288
288
-
### 6. Export Operations by Time Range
289
289
-
290
290
-
```go
291
291
-
// Export operations after a specific time
292
292
-
afterTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
293
293
-
operations, err := mgr.Export(ctx, afterTime, 5000)
294
294
-
if err != nil {
295
295
-
log.Fatal(err)
296
296
-
}
297
297
-
298
298
-
log.Printf("Exported %d operations", len(operations))
299
299
-
300
300
-
// Process operations
301
301
-
for _, op := range operations {
302
302
-
// Your processing logic
303
303
-
processOperation(op)
304
304
-
}
305
305
-
```
306
306
-
307
307
-
### 7. Periodic Update Pattern
308
308
-
309
309
-
```go
310
310
-
// Simple periodic updater
311
311
-
func runPeriodicUpdate(mgr *plcbundle.BundleManager, interval time.Duration) {
312
312
-
ticker := time.NewTicker(interval)
313
313
-
defer ticker.Stop()
314
314
-
315
315
-
for range ticker.C {
316
316
-
ctx := context.Background()
317
317
-
318
318
-
// Try to fetch next bundle
319
319
-
bundle, err := mgr.FetchNext(ctx)
320
320
-
if err != nil {
321
321
-
if strings.Contains(err.Error(), "insufficient operations") {
322
322
-
log.Println("Caught up!")
323
323
-
continue
324
324
-
}
325
325
-
log.Printf("Error: %v", err)
326
326
-
continue
327
327
-
}
328
328
-
329
329
-
log.Printf("New bundle %d: %d operations",
330
330
-
bundle.BundleNumber,
331
331
-
len(bundle.Operations))
332
332
-
333
333
-
// Process new operations
334
334
-
for _, op := range bundle.Operations {
335
335
-
handleOperation(op)
336
336
-
}
337
337
-
}
338
338
-
}
339
339
-
340
340
-
// Usage
341
341
-
go runPeriodicUpdate(mgr, 10*time.Minute)
342
342
-
```
343
343
-
344
344
-
### 8. Verify Integrity
345
345
-
346
346
-
```go
347
347
-
// Verify specific bundle
348
348
-
result, err := mgr.Verify(ctx, 1)
349
349
-
if err != nil {
350
350
-
log.Fatal(err)
351
351
-
}
352
352
-
353
353
-
if result.Valid {
354
354
-
log.Println("✓ Bundle is valid")
355
355
-
} else {
356
356
-
log.Printf("✗ Invalid: %v", result.Error)
357
357
-
}
358
358
-
359
359
-
// Verify entire chain
360
360
-
chainResult, err := mgr.VerifyChain(ctx)
361
361
-
if err != nil {
362
362
-
log.Fatal(err)
363
363
-
}
364
364
-
365
365
-
if chainResult.Valid {
366
366
-
log.Printf("✓ Chain verified: %d bundles", chainResult.ChainLength)
367
367
-
} else {
368
368
-
log.Printf("✗ Chain broken at bundle %d: %s",
369
369
-
chainResult.BrokenAt,
370
370
-
chainResult.Error)
371
371
-
}
57
57
+
bundle, _ := mgr.FetchNext(context.Background())
58
58
+
// Process bundle.Operations
372
59
```
373
60
374
374
-
### 9. Scan Directory (Re-index)
375
375
-
376
376
-
```go
377
377
-
// Scan directory and rebuild index from existing bundles
378
378
-
result, err := mgr.Scan()
379
379
-
if err != nil {
380
380
-
log.Fatal(err)
381
381
-
}
382
382
-
383
383
-
log.Printf("Scanned %d bundles", result.BundleCount)
384
384
-
if len(result.MissingGaps) > 0 {
385
385
-
log.Printf("Warning: Missing bundles: %v", result.MissingGaps)
386
386
-
}
387
387
-
```
388
388
-
389
389
-
### 10. Complete Example: Background Sync Service
390
390
-
391
391
-
```go
392
392
-
package main
393
393
-
394
394
-
import (
395
395
-
"context"
396
396
-
"log"
397
397
-
"os"
398
398
-
"os/signal"
399
399
-
"syscall"
400
400
-
"time"
401
401
-
402
402
-
plcbundle "tangled.org/atscan.net/plcbundle"
403
403
-
)
404
404
-
405
405
-
type PLCService struct {
406
406
-
mgr *plcbundle.BundleManager
407
407
-
updateCh chan struct{}
408
408
-
stopCh chan struct{}
409
409
-
}
410
410
-
411
411
-
func NewPLCService(bundleDir string) (*PLCService, error) {
412
412
-
mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
413
413
-
if err != nil {
414
414
-
return nil, err
415
415
-
}
416
416
-
417
417
-
return &PLCService{
418
418
-
mgr: mgr,
419
419
-
updateCh: make(chan struct{}, 1),
420
420
-
stopCh: make(chan struct{}),
421
421
-
}, nil
422
422
-
}
423
423
-
424
424
-
func (s *PLCService) Start() {
425
425
-
log.Println("Starting PLC service...")
426
426
-
427
427
-
// Initial scan
428
428
-
if _, err := s.mgr.Scan(); err != nil {
429
429
-
log.Printf("Scan warning: %v", err)
430
430
-
}
431
431
-
432
432
-
// Start update loop
433
433
-
go s.updateLoop()
434
434
-
435
435
-
// Periodic trigger
436
436
-
go s.periodicTrigger(5 * time.Minute)
437
437
-
}
61
61
+
[See full library documentation →](./docs/library.md)
438
62
439
439
-
func (s *PLCService) updateLoop() {
440
440
-
for {
441
441
-
select {
442
442
-
case <-s.updateCh:
443
443
-
s.fetchNewBundles()
444
444
-
case <-s.stopCh:
445
445
-
return
446
446
-
}
447
447
-
}
448
448
-
}
63
63
+
### As a CLI Tool
449
64
450
450
-
func (s *PLCService) periodicTrigger(interval time.Duration) {
451
451
-
ticker := time.NewTicker(interval)
452
452
-
defer ticker.Stop()
453
453
-
454
454
-
for {
455
455
-
select {
456
456
-
case <-ticker.C:
457
457
-
s.TriggerUpdate()
458
458
-
case <-s.stopCh:
459
459
-
return
460
460
-
}
461
461
-
}
462
462
-
}
65
65
+
```bash
66
66
+
# Install
67
67
+
go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
463
68
464
464
-
func (s *PLCService) TriggerUpdate() {
465
465
-
select {
466
466
-
case s.updateCh <- struct{}{}:
467
467
-
default:
468
468
-
// Update already in progress
469
469
-
}
470
470
-
}
471
471
-
472
472
-
func (s *PLCService) fetchNewBundles() {
473
473
-
ctx := context.Background()
474
474
-
fetched := 0
475
475
-
476
476
-
for {
477
477
-
bundle, err := s.mgr.FetchNext(ctx)
478
478
-
if err != nil {
479
479
-
if isEndOfData(err) {
480
480
-
if fetched > 0 {
481
481
-
log.Printf("✓ Fetched %d new bundles", fetched)
482
482
-
}
483
483
-
return
484
484
-
}
485
485
-
log.Printf("Fetch error: %v", err)
486
486
-
return
487
487
-
}
488
488
-
489
489
-
fetched++
490
490
-
log.Printf("Bundle %06d: %d operations",
491
491
-
bundle.BundleNumber,
492
492
-
len(bundle.Operations))
493
493
-
}
494
494
-
}
495
495
-
496
496
-
func (s *PLCService) GetBundles() []*plcbundle.BundleMetadata {
497
497
-
return s.mgr.GetIndex().GetBundles()
498
498
-
}
499
499
-
500
500
-
func (s *PLCService) GetOperations(bundleNum int) ([]plcbundle.PLCOperation, error) {
501
501
-
ctx := context.Background()
502
502
-
bundle, err := s.mgr.Load(ctx, bundleNum)
503
503
-
if err != nil {
504
504
-
return nil, err
505
505
-
}
506
506
-
return bundle.Operations, nil
507
507
-
}
508
508
-
509
509
-
func (s *PLCService) Stop() {
510
510
-
close(s.stopCh)
511
511
-
s.mgr.Close()
512
512
-
}
513
513
-
514
514
-
func main() {
515
515
-
service, err := NewPLCService("./plc_data")
516
516
-
if err != nil {
517
517
-
log.Fatal(err)
518
518
-
}
519
519
-
520
520
-
service.Start()
521
521
-
522
522
-
// Wait for interrupt
523
523
-
sigCh := make(chan os.Signal, 1)
524
524
-
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
525
525
-
<-sigCh
526
526
-
527
527
-
log.Println("Shutting down...")
528
528
-
service.Stop()
529
529
-
}
530
530
-
```
531
531
-
532
532
-
## CLI Tool Usage
533
533
-
534
534
-
### Fetch bundles
535
535
-
536
536
-
```bash
537
537
-
# Fetch next bundle
69
69
+
# Fetch bundles
538
70
plcbundle fetch
539
71
540
540
-
# Fetch specific number of bundles
541
541
-
plcbundle fetch -count 10
542
542
-
543
543
-
# Fetch all available bundles
544
544
-
plcbundle fetch -count 0
545
545
-
```
546
546
-
547
547
-
### Scan directory
72
72
+
# Clone from remote
73
73
+
plcbundle clone https://plc.example.com
548
74
549
549
-
```bash
550
550
-
# Scan and rebuild index
551
551
-
plcbundle scan
552
552
-
```
553
553
-
554
554
-
### Verify integrity
555
555
-
556
556
-
```bash
557
557
-
# Verify specific bundle
558
558
-
plcbundle verify -bundle 1
559
559
-
560
560
-
# Verify entire chain
75
75
+
# Verify integrity
561
76
plcbundle verify
562
562
-
563
563
-
# Verbose output
564
564
-
plcbundle verify -v
565
77
```
566
78
567
567
-
### Show information
79
79
+
[See full CLI reference →](./docs/cli.md)
568
80
569
569
-
```bash
570
570
-
# General info
571
571
-
plcbundle info
81
81
+
## Key Features
572
82
573
573
-
# Specific bundle info
574
574
-
plcbundle info -bundle 1
575
575
-
```
83
83
+
- 📦 Automatic bundle management (10,000 operations each)
84
84
+
- 🔄 Transparent synchronization with PLC directory
85
85
+
- 🗜️ Efficient zstd compression
86
86
+
- ✅ Cryptographic verification (SHA-256 + chain validation)
87
87
+
- 🔍 Fast indexing and gap detection
88
88
+
- 🌐 HTTP server for hosting bundles
89
89
+
- 🔌 WebSocket streaming support
576
90
577
577
-
### Export operations
91
91
+
## Installation
578
92
579
93
```bash
580
580
-
# Export operations to stdout (JSONL)
581
581
-
plcbundle export -count 1000 > operations.jsonl
94
94
+
# Library
95
95
+
go get tangled.org/atscan.net/plcbundle
582
96
583
583
-
# Export after specific time
584
584
-
plcbundle export -after "2024-01-01T00:00:00Z" -count 5000
97
97
+
# CLI tool
98
98
+
go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
585
99
```
586
100
587
587
-
### Backfill
101
101
+
## Use Cases
588
102
589
589
-
```bash
590
590
-
# Fetch all bundles and stream to stdout
591
591
-
plcbundle backfill > all_operations.jsonl
592
592
-
593
593
-
# Start from specific bundle
594
594
-
plcbundle backfill -start 100 -end 200
595
595
-
```
596
596
-
597
597
-
## API Reference
598
598
-
599
599
-
### Types
600
600
-
601
601
-
```go
602
602
-
type BundleManager struct { ... }
603
603
-
type Bundle struct {
604
604
-
BundleNumber int
605
605
-
StartTime time.Time
606
606
-
EndTime time.Time
607
607
-
Operations []PLCOperation
608
608
-
DIDCount int
609
609
-
Hash string
610
610
-
// ...
611
611
-
}
612
612
-
613
613
-
type PLCOperation struct {
614
614
-
DID string
615
615
-
Operation map[string]interface{}
616
616
-
CID string
617
617
-
CreatedAt time.Time
618
618
-
RawJSON []byte
619
619
-
}
620
620
-
```
621
621
-
622
622
-
### Methods
623
623
-
624
624
-
```go
625
625
-
// Create
626
626
-
New(bundleDir, plcURL string) (*BundleManager, error)
103
103
+
- **Archiving**: Create verifiable backups of PLC operations
104
104
+
- **Mirroring**: Host your own PLC directory mirror
105
105
+
- **Research**: Analyze historical DID operations
106
106
+
- **Compliance**: Maintain tamper-evident audit trails
627
107
628
628
-
// Sync
629
629
-
FetchNext(ctx) (*Bundle, error)
630
630
-
Export(ctx, afterTime, count) ([]PLCOperation, error)
108
108
+
## Security Model
631
109
632
632
-
// Query
633
633
-
Load(ctx, bundleNumber) (*Bundle, error)
634
634
-
GetIndex() *Index
635
635
-
GetInfo() map[string]interface{}
110
110
+
Bundles are cryptographically chained but require external verification:
111
111
+
- ✅ Verify against original PLC directory
112
112
+
- ✅ Compare with multiple independent mirrors
113
113
+
- ✅ Check published root and head hashes
114
114
+
- ✅ Anyone can reproduce bundles from PLC directory
636
115
637
637
-
// Verify
638
638
-
Verify(ctx, bundleNumber) (*VerificationResult, error)
639
639
-
VerifyChain(ctx) (*ChainVerificationResult, error)
116
116
+
## Reference Implementations
640
117
641
641
-
// Manage
642
642
-
Scan() (*DirectoryScanResult, error)
643
643
-
Close()
644
644
-
```
118
118
+
- [TypeScript, Python, Ruby](https://tangled.org/@atscan.net/plcbundle-js/blob/main/plcbundle.ts)
645
119
646
646
-
## Configuration
120
120
+
## Documentation
647
121
648
648
-
```go
649
649
-
type Config struct {
650
650
-
BundleDir string // Storage directory
651
651
-
CompressionLevel CompressionLevel // Compression level
652
652
-
VerifyOnLoad bool // Verify hashes when loading
653
653
-
Logger Logger // Custom logger
654
654
-
}
655
655
-
```
122
122
+
- [Library Guide](./docs/library.md) - Comprehensive API documentation
123
123
+
- [CLI Guide](./docs/cli.md) - Command-line tool usage
124
124
+
- [Specification](./docs/specification.md) - Technical format specification
125
125
+
<!--- [Examples](./docs/examples/) - Common patterns and recipes-->
656
126
657
127
## License
658
128
···
660
130
661
131
## Contributing
662
132
663
663
-
Contributions welcome! Please open an issue or PR.
133
133
+
Contributions welcome! Please open an issue or PR.
SECURITY
docs/security.md
SPECIFICATION.md
docs/specification.md
+858
docs/cli.md
···
1
1
+
# CLI Guide
2
2
+
3
3
+
A practical guide to using the `plcbundle` command-line tool.
4
4
+
5
5
+
## Table of Contents
6
6
+
7
7
+
- [Getting Started](#getting-started)
8
8
+
- [Basic Workflows](#basic-workflows)
9
9
+
- [Advanced Usage](#advanced-usage)
10
10
+
- [Best Practices](#best-practices)
11
11
+
- [Troubleshooting](#troubleshooting)
12
12
+
13
13
+
---
14
14
+
15
15
+
## Getting Started
16
16
+
17
17
+
### Installation
18
18
+
19
19
+
```bash
20
20
+
go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
21
21
+
```
22
22
+
23
23
+
Verify it's installed:
24
24
+
```bash
25
25
+
plcbundle version
26
26
+
# plcbundle version dev
27
27
+
```
28
28
+
29
29
+
### Your First Bundle
30
30
+
31
31
+
Let's fetch your first bundle from the PLC directory:
32
32
+
33
33
+
```bash
34
34
+
# Create a directory for your bundles
35
35
+
mkdir my-plc-archive
36
36
+
cd my-plc-archive
37
37
+
38
38
+
# Fetch one bundle
39
39
+
plcbundle fetch
40
40
+
```
41
41
+
42
42
+
You'll see output like:
43
43
+
```
44
44
+
Working in: /Users/you/my-plc-archive
45
45
+
Starting from bundle 000001
46
46
+
Fetching all available bundles...
47
47
+
48
48
+
Preparing bundle 000001 (mempool: 0 ops)...
49
49
+
Fetching more operations (have 0/10000)...
50
50
+
Fetch #1: requesting 1000 operations (mempool: 0)
51
51
+
Added 1000 new operations (mempool now: 1000)
52
52
+
53
53
+
... (continues fetching) ...
54
54
+
55
55
+
✓ Bundle 000001 ready (10000 ops, mempool: 0 remaining)
56
56
+
✓ Saved bundle 000001 (10000 operations, 8543 DIDs)
57
57
+
58
58
+
✓ Fetch complete: 1 bundles retrieved
59
59
+
```
60
60
+
61
61
+
**What just happened?**
62
62
+
63
63
+
plcbundle created two files:
64
64
+
- `000001.jsonl.zst` - Your first bundle (10,000 PLC operations, compressed)
65
65
+
- `index.json` - Index tracking bundle metadata and hashes
66
66
+
67
67
+
### Understanding the Files
68
68
+
69
69
+
**Bundle files** (`000001.jsonl.zst`):
70
70
+
- Contain exactly 10,000 operations each
71
71
+
- Compressed with zstd (~5x compression)
72
72
+
- Named with 6-digit zero-padding
73
73
+
- Immutable once created
74
74
+
75
75
+
**Index file** (`index.json`):
76
76
+
- Maps bundle numbers to metadata
77
77
+
- Contains cryptographic hashes
78
78
+
- Tracks the bundle chain
79
79
+
- Updated when bundles are added
80
80
+
81
81
+
**Mempool files** (`plc_mempool_*.jsonl`):
82
82
+
- Temporary staging for operations
83
83
+
- Auto-managed by plcbundle
84
84
+
- Safe to ignore (they clean up automatically)
85
85
+
86
86
+
### Check What You Have
87
87
+
88
88
+
```bash
89
89
+
plcbundle info
90
90
+
```
91
91
+
92
92
+
You'll see a summary:
93
93
+
```
94
94
+
═══════════════════════════════════════════════════════════
95
95
+
PLC Bundle Repository Overview
96
96
+
═══════════════════════════════════════════════════════════
97
97
+
98
98
+
📊 Summary
99
99
+
Bundles: 1
100
100
+
Range: 000001 → 000001
101
101
+
Compressed: 505 KB
102
102
+
Operations: 10,000 records
103
103
+
104
104
+
🔐 Chain Hashes
105
105
+
Root (bundle 000001):
106
106
+
8f4e3a2d9c1b7f5e3a2d9c1b7f5e3a2d9c1b7f5e3a2d9c1b7f5e3a2d9c1b7f5e
107
107
+
```
108
108
+
109
109
+
---
110
110
+
111
111
+
## Basic Workflows
112
112
+
113
113
+
### Workflow 1: Building a Complete Archive
114
114
+
115
115
+
**Goal:** Download all historical PLC bundles to create a complete archive.
116
116
+
117
117
+
```bash
118
118
+
# Fetch all available bundles
119
119
+
plcbundle fetch -count 0
120
120
+
```
121
121
+
122
122
+
The `-count 0` means "fetch everything available". This will:
123
123
+
- Start from bundle 1 (or continue from where you left off)
124
124
+
- Keep fetching until caught up
125
125
+
- Create bundles of 10,000 operations each
126
126
+
- Stop when no more complete bundles can be formed
127
127
+
128
128
+
**Tip:** This can take a while. You can interrupt with Ctrl+C and resume later - just run the same command again.
129
129
+
130
130
+
### Workflow 2: Keeping Up-to-Date
131
131
+
132
132
+
**Goal:** Regularly sync new operations as they arrive.
133
133
+
134
134
+
```bash
135
135
+
# Run periodically (cron, systemd timer, etc.)
136
136
+
plcbundle fetch
137
137
+
```
138
138
+
139
139
+
Or use a simple script:
140
140
+
```bash
141
141
+
#!/bin/bash
142
142
+
# sync-plc.sh
143
143
+
cd /path/to/plc_data
144
144
+
plcbundle fetch
145
145
+
```
146
146
+
147
147
+
Run it every 5-10 minutes:
148
148
+
```bash
149
149
+
# Crontab
150
150
+
*/10 * * * * /path/to/sync-plc.sh
151
151
+
```
152
152
+
153
153
+
**What happens when there aren't enough operations?**
154
154
+
155
155
+
If fewer than 10,000 new operations exist, they're stored in the mempool:
156
156
+
```bash
157
157
+
plcbundle mempool
158
158
+
# Mempool Status:
159
159
+
# Operations: 3,482
160
160
+
# Progress: 34.8% (3482/10000)
161
161
+
# Need 6,518 more operations
162
162
+
```
163
163
+
164
164
+
The bundle will be created automatically once 10,000 operations arrive.
165
165
+
166
166
+
### Workflow 3: Cloning from Another Server
167
167
+
168
168
+
**Goal:** Quickly sync bundles from an existing plcbundle server instead of fetching from PLC.
169
169
+
170
170
+
**Why?** Much faster! Downloading pre-made bundles is faster than fetching and bundling operations yourself.
171
171
+
172
172
+
```bash
173
173
+
# Clone from a public mirror
174
174
+
plcbundle clone https://plc.example.com
175
175
+
```
176
176
+
177
177
+
With progress tracking:
178
178
+
```
179
179
+
Cloning from: https://plc.example.com
180
180
+
Remote has 8,547 bundles
181
181
+
Downloading 8,547 bundles (4.2 GB)
182
182
+
183
183
+
[████████░░░░░░] 68.2% | 5,829/8,547 | 42.5/s | 21.3 MB/s | ETA: 1m 4s
184
184
+
```
185
185
+
186
186
+
**Resume after interruption:**
187
187
+
188
188
+
Press Ctrl+C and the download stops gracefully:
189
189
+
```
190
190
+
⚠️ Interrupt received! Finishing current downloads and saving progress...
191
191
+
192
192
+
⚠️ Download interrupted by user
193
193
+
194
194
+
Results:
195
195
+
Downloaded: 5,829
196
196
+
Total size: 2.9 GB
197
197
+
198
198
+
✓ Progress saved. Re-run the clone command to resume.
199
199
+
```
200
200
+
201
201
+
Just run the same command again:
202
202
+
```bash
203
203
+
plcbundle clone https://plc.example.com
204
204
+
# Skips 5,829 existing bundles, downloads the rest
205
205
+
```
206
206
+
207
207
+
**Speed it up with more workers:**
208
208
+
```bash
209
209
+
plcbundle clone https://plc.example.com -workers 16
210
210
+
```
211
211
+
212
212
+
### Workflow 4: Verifying Your Archive
213
213
+
214
214
+
**Goal:** Ensure your bundles are intact and unmodified.
215
215
+
216
216
+
```bash
217
217
+
# Verify the entire chain
218
218
+
plcbundle verify
219
219
+
```
220
220
+
221
221
+
Output:
222
222
+
```
223
223
+
Verifying chain of 100 bundles...
224
224
+
[ 10%] Verified 10/100 bundles...
225
225
+
[ 50%] Verified 50/100 bundles...
226
226
+
[100%] Verified 100/100 bundles...
227
227
+
228
228
+
✓ Chain is valid (100 bundles verified)
229
229
+
Chain head: 8f4e3a2d9c1b7f5e...
230
230
+
```
231
231
+
232
232
+
**What's being verified?**
233
233
+
234
234
+
1. **File integrity**: Each bundle's compressed data matches its hash
235
235
+
2. **Chain integrity**: Each bundle correctly links to the previous one
236
236
+
3. **Complete chain**: No broken links from bundle 1 to the last
237
237
+
238
238
+
**Verify just one bundle:**
239
239
+
```bash
240
240
+
plcbundle verify -bundle 42
241
241
+
# ✓ Bundle 000042 is valid
242
242
+
```
243
243
+
244
244
+
### Workflow 5: Sharing Your Archive
245
245
+
246
246
+
**Goal:** Run an HTTP server so others can clone your bundles.
247
247
+
248
248
+
```bash
249
249
+
plcbundle serve
250
250
+
```
251
251
+
252
252
+
Output:
253
253
+
```
254
254
+
Starting plcbundle HTTP server...
255
255
+
Directory: /Users/you/plc_data
256
256
+
Listening: http://127.0.0.1:8080
257
257
+
Sync mode: disabled
258
258
+
Bundles available: 100
259
259
+
260
260
+
Press Ctrl+C to stop
261
261
+
```
262
262
+
263
263
+
Now others can clone from you:
264
264
+
```bash
265
265
+
# From another machine
266
266
+
plcbundle clone http://your-server:8080
267
267
+
```
268
268
+
269
269
+
**Serve on a different port:**
270
270
+
```bash
271
271
+
plcbundle serve -port 9000 -host 0.0.0.0
272
272
+
# Listening on all interfaces, port 9000
273
273
+
```
274
274
+
275
275
+
**Auto-sync while serving:**
276
276
+
```bash
277
277
+
plcbundle serve -sync -sync-interval 5m
278
278
+
# Automatically fetches new bundles every 5 minutes
279
279
+
```
280
280
+
281
281
+
---
282
282
+
283
283
+
## Advanced Usage
284
284
+
285
285
+
### Working with Mempool
286
286
+
287
287
+
The mempool is a staging area for operations waiting to form a complete bundle.
288
288
+
289
289
+
**Check mempool status:**
290
290
+
```bash
291
291
+
plcbundle mempool
292
292
+
```
293
293
+
294
294
+
Output:
295
295
+
```
296
296
+
Mempool Status:
297
297
+
Target bundle: 000101
298
298
+
Operations: 7,234
299
299
+
Can create bundle: false (need 10000)
300
300
+
Progress: 72.3% (7234/10000)
301
301
+
[████████████████████████████░░░░░░░░░░░░]
302
302
+
First operation: 2024-12-01 10:23:45
303
303
+
Last operation: 2024-12-15 15:47:23
304
304
+
```
305
305
+
306
306
+
**Export mempool operations:**
307
307
+
```bash
308
308
+
plcbundle mempool -export > recent_operations.jsonl
309
309
+
# Exported 7,234 operations from mempool
310
310
+
```
311
311
+
312
312
+
**Clear mempool (use with caution):**
313
313
+
```bash
314
314
+
plcbundle mempool -clear
315
315
+
# ⚠ This will clear 7,234 operations from the mempool.
316
316
+
# Are you sure? [y/N]: y
317
317
+
# ✓ Mempool cleared
318
318
+
```
319
319
+
320
320
+
**When to clear mempool:**
321
321
+
- After corrupted operations
322
322
+
- When restarting from scratch
323
323
+
- During development/testing
324
324
+
325
325
+
**Validate mempool chronology:**
326
326
+
```bash
327
327
+
plcbundle mempool -validate
328
328
+
# ✓ Mempool validation passed
329
329
+
```
330
330
+
331
331
+
### Exporting Operations
332
332
+
333
333
+
**Goal:** Extract operations from bundles for analysis or processing.
334
334
+
335
335
+
**Export recent operations:**
336
336
+
```bash
337
337
+
plcbundle export -count 5000 > operations.jsonl
338
338
+
# Exported 5000 operations
339
339
+
```
340
340
+
341
341
+
**Export operations after a specific time:**
342
342
+
```bash
343
343
+
plcbundle export -after "2024-01-01T00:00:00Z" -count 10000 > jan_2024.jsonl
344
344
+
```
345
345
+
346
346
+
**Stream all operations (backfill):**
347
347
+
```bash
348
348
+
plcbundle backfill > all_operations.jsonl
349
349
+
# This streams ALL operations from all bundles
350
350
+
```
351
351
+
352
352
+
**Backfill specific range:**
353
353
+
```bash
354
354
+
plcbundle backfill -start 1 -end 100 > first_100_bundles.jsonl
355
355
+
```
356
356
+
357
357
+
### Rebuilding the Index
358
358
+
359
359
+
**Goal:** Regenerate `index.json` from bundle files.
360
360
+
361
361
+
**When you need this:**
362
362
+
- Downloaded bundle files manually
363
363
+
- Corrupted index file
364
364
+
- Migrated from another system
365
365
+
- After specification updates (during preview)
366
366
+
367
367
+
```bash
368
368
+
plcbundle rebuild
369
369
+
```
370
370
+
371
371
+
Output:
372
372
+
```
373
373
+
Rebuilding index from: /Users/you/plc_data
374
374
+
Using 8 workers
375
375
+
Found 100 bundle files
376
376
+
377
377
+
Processing bundles:
378
378
+
[████████████████████████████████████████] 100% | 100/100 | 25.0/s
379
379
+
380
380
+
✓ Index rebuilt in 4.2s
381
381
+
Total bundles: 100
382
382
+
Compressed size: 50.5 MB
383
383
+
Uncompressed size: 252.3 MB
384
384
+
Average speed: 23.8 bundles/sec
385
385
+
386
386
+
✓ Chain verified: All bundles linked correctly
387
387
+
```
388
388
+
389
389
+
**Speed it up:**
390
390
+
```bash
391
391
+
plcbundle rebuild -workers 16
392
392
+
```
393
393
+
394
394
+
### Comparing with Remote
395
395
+
396
396
+
**Goal:** Check differences between your local archive and a remote server.
397
397
+
398
398
+
```bash
399
399
+
plcbundle compare https://plc.example.com
400
400
+
```
401
401
+
402
402
+
Output:
403
403
+
```
404
404
+
Comparison Results
405
405
+
══════════════════
406
406
+
407
407
+
Summary
408
408
+
───────
409
409
+
Local bundles: 95
410
410
+
Target bundles: 100
411
411
+
Common bundles: 95
412
412
+
Missing bundles: 5 ⚠️
413
413
+
Hash mismatches: 0 ✓
414
414
+
415
415
+
Missing Bundles (in target but not local)
416
416
+
──────────────────────────────────────────
417
417
+
000096
418
418
+
000097
419
419
+
000098
420
420
+
000099
421
421
+
000100
422
422
+
423
423
+
✗ Indexes have differences
424
424
+
```
425
425
+
426
426
+
**Auto-fetch missing bundles:**
427
427
+
```bash
428
428
+
plcbundle compare https://plc.example.com --fetch-missing
429
429
+
```
430
430
+
431
431
+
This will:
432
432
+
1. Compare indexes
433
433
+
2. Show differences
434
434
+
3. Download missing bundles
435
435
+
4. Update your index
436
436
+
437
437
+
### Serving with WebSocket Streaming
438
438
+
439
439
+
**Goal:** Provide real-time streaming of operations via WebSocket.
440
440
+
441
441
+
```bash
442
442
+
plcbundle serve -sync -websocket
443
443
+
```
444
444
+
445
445
+
Output:
446
446
+
```
447
447
+
Starting plcbundle HTTP server...
448
448
+
Listening: http://127.0.0.1:8080
449
449
+
Sync mode: ENABLED
450
450
+
WebSocket: ENABLED (ws://127.0.0.1:8080/ws)
451
451
+
```
452
452
+
453
453
+
**Connect with websocat:**
454
454
+
```bash
455
455
+
# Stream all operations from the beginning
456
456
+
websocat ws://localhost:8080/ws
457
457
+
458
458
+
# Stream from cursor 100,000
459
459
+
websocat 'ws://localhost:8080/ws?cursor=100000'
460
460
+
```
461
461
+
462
462
+
**What's a cursor?**
463
463
+
464
464
+
Cursor = `(bundle_number * 10000) + position_in_bundle`
465
465
+
466
466
+
Example:
467
467
+
- Bundle 1, position 0 = cursor 0
468
468
+
- Bundle 1, position 500 = cursor 500
469
469
+
- Bundle 10, position 0 = cursor 90,000
470
470
+
- Bundle 10, position 2,345 = cursor 92,345
471
471
+
472
472
+
**Stream and process:**
473
473
+
```bash
474
474
+
websocat ws://localhost:8080/ws | jq 'select(.did | startswith("did:plc:"))'
475
475
+
# Filter operations in real-time with jq
476
476
+
```
477
477
+
478
478
+
---
479
479
+
480
480
+
## Best Practices
481
481
+
482
482
+
### 1. Regular Verification
483
483
+
484
484
+
Verify your archive periodically:
485
485
+
```bash
486
486
+
# Weekly cron job
487
487
+
0 0 * * 0 cd /path/to/plc_data && plcbundle verify
488
488
+
```
489
489
+
490
490
+
### 2. Backup Strategy
491
491
+
492
492
+
**Back up the index frequently:**
493
493
+
```bash
494
494
+
cp index.json index.json.backup
495
495
+
```
496
496
+
497
497
+
**Why?** The index is small but critical. Bundles can be rebuilt, but the index tracks everything.
498
498
+
499
499
+
**Back up bundles incrementally:**
500
500
+
```bash
501
501
+
# Rsync to backup server
502
502
+
rsync -av --progress *.jsonl.zst backup-server:/plc_archive/
503
503
+
```
504
504
+
505
505
+
### 3. Storage Planning
506
506
+
507
507
+
**Size estimates:**
508
508
+
- ~500 KB per bundle (compressed)
509
509
+
- 10,000 operations per bundle
510
510
+
- ~50 bytes per operation (compressed)
511
511
+
512
512
+
**Current PLC size** (check with):
513
513
+
```bash
514
514
+
curl https://plc.directory/export?count=1 | jq -r '.createdAt'
515
515
+
# Then estimate: ~10,000 bundles as of 2025 = ~5 GB
516
516
+
```
517
517
+
518
518
+
### 4. Monitoring
519
519
+
520
520
+
**Create a health check script:**
521
521
+
```bash
522
522
+
#!/bin/bash
523
523
+
# health-check.sh
524
524
+
525
525
+
cd /path/to/plc_data
526
526
+
527
527
+
# Check last bundle age
528
528
+
LAST_BUNDLE=$(plcbundle info | grep "Last Op" | cut -d: -f2-)
529
529
+
AGE=$(plcbundle info | grep "Age" | cut -d: -f2-)
530
530
+
531
531
+
echo "Last operation: $LAST_BUNDLE"
532
532
+
echo "Age: $AGE"
533
533
+
534
534
+
# Verify chain
535
535
+
if ! plcbundle verify -bundle $(plcbundle info | grep "Last bundle" | awk '{print $3}'); then
536
536
+
echo "ERROR: Verification failed!"
537
537
+
exit 1
538
538
+
fi
539
539
+
540
540
+
echo "Status: OK"
541
541
+
```
542
542
+
543
543
+
### 5. Network Efficiency
544
544
+
545
545
+
**Use clone instead of fetch when possible:**
546
546
+
```bash
547
547
+
# Slower: Fetch from PLC directory
548
548
+
plcbundle fetch
549
549
+
550
550
+
# Faster: Clone from mirror
551
551
+
plcbundle clone https://plc-mirror.example.com
552
552
+
```
553
553
+
554
554
+
**Rate limit considerations:**
555
555
+
556
556
+
The PLC directory rate limits API requests. plcbundle handles this automatically with:
557
557
+
- Exponential backoff
558
558
+
- Automatic retry
559
559
+
- ~90 requests/minute limit
560
560
+
561
561
+
### 6. Disk Space Management
562
562
+
563
563
+
**Check sizes:**
564
564
+
```bash
565
565
+
plcbundle info | grep -E "(Compressed|Bundles)"
566
566
+
# Bundles: 100
567
567
+
# Compressed: 50.5 MB
568
568
+
```
569
569
+
570
570
+
**Clean old mempools (safe):**
571
571
+
```bash
572
572
+
# Old mempools might linger if process was killed
573
573
+
rm plc_mempool_*.jsonl
574
574
+
plcbundle fetch # Will recreate if needed
575
575
+
```
576
576
+
577
577
+
---
578
578
+
579
579
+
## Troubleshooting
580
580
+
581
581
+
### Bundle Count Mismatch
582
582
+
583
583
+
**Problem:**
584
584
+
```
585
585
+
Found 100 bundle files but index only has 95 entries
586
586
+
```
587
587
+
588
588
+
**Solution:**
589
589
+
```bash
590
590
+
plcbundle rebuild
591
591
+
```
592
592
+
593
593
+
This rescans all bundles and rebuilds the index.
594
594
+
595
595
+
---
596
596
+
597
597
+
### Hash Verification Failed
598
598
+
599
599
+
**Problem:**
600
600
+
```
601
601
+
✗ Bundle 000042 hash verification failed
602
602
+
Expected hash: a1b2c3d4...
603
603
+
Actual hash: f6e5d4c3...
604
604
+
```
605
605
+
606
606
+
**Possible causes:**
607
607
+
1. Corrupted file during download
608
608
+
2. Bundle was modified
609
609
+
3. Downloaded from untrusted source
610
610
+
611
611
+
**Solution:**
612
612
+
613
613
+
If you have a trusted source:
614
614
+
```bash
615
615
+
# Re-download specific bundle
616
616
+
plcbundle clone https://trusted-mirror.com -workers 1
617
617
+
# (It will skip existing and re-download corrupt ones)
618
618
+
```
619
619
+
620
620
+
Or fetch fresh:
621
621
+
```bash
622
622
+
# Delete corrupted bundle
623
623
+
rm 000042.jsonl.zst
624
624
+
625
625
+
# Rebuild index (marks it as missing)
626
626
+
plcbundle rebuild
627
627
+
628
628
+
# Fetch it again
629
629
+
plcbundle fetch
630
630
+
```
631
631
+
632
632
+
---
633
633
+
634
634
+
### Chain Broken Error
635
635
+
636
636
+
**Problem:**
637
637
+
```
638
638
+
✗ Chain broken at bundle 000050
639
639
+
Expected parent: a1b2c3d4...
640
640
+
Actual parent: f6e5d4c3...
641
641
+
```
642
642
+
643
643
+
**Meaning:** Bundle 50's parent hash doesn't match bundle 49's hash.
644
644
+
645
645
+
**Solution:**
646
646
+
647
647
+
This means bundles came from incompatible sources. You need a consistent chain:
648
648
+
649
649
+
```bash
650
650
+
# Start fresh or clone from one trusted source
651
651
+
rm *.jsonl.zst index.json
652
652
+
plcbundle clone https://trusted-mirror.com
653
653
+
```
654
654
+
655
655
+
---
656
656
+
657
657
+
### Out of Disk Space
658
658
+
659
659
+
**Problem:**
660
660
+
```
661
661
+
Error saving bundle: no space left on device
662
662
+
```
663
663
+
664
664
+
**Solution:**
665
665
+
666
666
+
Check space:
667
667
+
```bash
668
668
+
df -h .
669
669
+
```
670
670
+
671
671
+
Free up space or move to larger disk:
672
672
+
```bash
673
673
+
# Move to new location
674
674
+
mv /old/path/* /new/large/disk/
675
675
+
cd /new/large/disk/
676
676
+
plcbundle info # Verify it works
677
677
+
```
678
678
+
679
679
+
---
680
680
+
681
681
+
### Fetch Stuck / No Progress
682
682
+
683
683
+
**Problem:** `plcbundle fetch` runs but doesn't create bundles.
684
684
+
685
685
+
**Check mempool:**
686
686
+
```bash
687
687
+
plcbundle mempool
688
688
+
# Operations: 3,482
689
689
+
# Need 6,518 more operations
690
690
+
```
691
691
+
692
692
+
**Meaning:** Not enough operations yet for a complete bundle.
693
693
+
694
694
+
**Solutions:**
695
695
+
696
696
+
1. **Wait** - More operations will arrive
697
697
+
2. **Check PLC connectivity:**
698
698
+
```bash
699
699
+
curl https://plc.directory/export?count=1
700
700
+
```
701
701
+
702
702
+
3. **Check rate limits:**
703
703
+
```bash
704
704
+
# Look for 429 errors in output
705
705
+
plcbundle fetch -count 1
706
706
+
```
707
707
+
708
708
+
---
709
709
+
710
710
+
### Port Already in Use
711
711
+
712
712
+
**Problem:**
713
713
+
```
714
714
+
Server error: listen tcp :8080: bind: address already in use
715
715
+
```
716
716
+
717
717
+
**Solution:**
718
718
+
719
719
+
Use different port:
720
720
+
```bash
721
721
+
plcbundle serve -port 9000
722
722
+
```
723
723
+
724
724
+
Or find what's using port 8080:
725
725
+
```bash
726
726
+
# macOS/Linux
727
727
+
lsof -i :8080
728
728
+
729
729
+
# Kill it if needed
730
730
+
kill <PID>
731
731
+
```
732
732
+
733
733
+
---
734
734
+
735
735
+
### WebSocket Connection Drops
736
736
+
737
737
+
**Problem:** WebSocket streaming stops after a few minutes.
738
738
+
739
739
+
**Causes:**
740
740
+
- Reverse proxy timeout
741
741
+
- Network timeout
742
742
+
- Client timeout
743
743
+
744
744
+
**Solutions:**
745
745
+
746
746
+
1. **Increase client timeout** (if using websocat):
747
747
+
```bash
748
748
+
websocat -t ws://localhost:8080/ws
749
749
+
```
750
750
+
751
751
+
2. **Configure reverse proxy** (nginx):
752
752
+
```nginx
753
753
+
location /ws {
754
754
+
proxy_pass http://localhost:8080;
755
755
+
proxy_http_version 1.1;
756
756
+
proxy_set_header Upgrade $http_upgrade;
757
757
+
proxy_set_header Connection "upgrade";
758
758
+
proxy_read_timeout 86400; # 24 hours
759
759
+
}
760
760
+
```
761
761
+
762
762
+
---
763
763
+
764
764
+
### Memory Usage High
765
765
+
766
766
+
**Problem:** `plcbundle` using lots of RAM during rebuild.
767
767
+
768
768
+
**Cause:** Large bundles being processed simultaneously.
769
769
+
770
770
+
**Solution:**
771
771
+
772
772
+
Reduce workers:
773
773
+
```bash
774
774
+
plcbundle rebuild -workers 2
775
775
+
```
776
776
+
777
777
+
Or increase system limits:
778
778
+
```bash
779
779
+
# Check current limits
780
780
+
ulimit -a
781
781
+
782
782
+
# Increase if needed (Linux)
783
783
+
ulimit -v 4000000 # 4GB virtual memory
784
784
+
```
785
785
+
786
786
+
---
787
787
+
788
788
+
### Can't Clone from Remote
789
789
+
790
790
+
**Problem:**
791
791
+
```
792
792
+
Error loading remote index: failed to download: connection refused
793
793
+
```
794
794
+
795
795
+
**Checklist:**
796
796
+
797
797
+
1. **Is URL correct?**
798
798
+
```bash
799
799
+
curl https://remote-server.com/index.json
800
800
+
```
801
801
+
802
802
+
2. **Is server running?**
803
803
+
```bash
804
804
+
# On remote server
805
805
+
plcbundle serve
806
806
+
```
807
807
+
808
808
+
3. **Firewall blocking?**
809
809
+
```bash
810
810
+
# Test connectivity
811
811
+
telnet remote-server.com 8080
812
812
+
```
813
813
+
814
814
+
4. **HTTPS certificate issues?**
815
815
+
```bash
816
816
+
# Test with curl
817
817
+
curl -v https://remote-server.com/index.json
818
818
+
```
819
819
+
820
820
+
---
821
821
+
822
822
+
## Quick Reference
823
823
+
824
824
+
```bash
825
825
+
# Sync
826
826
+
plcbundle fetch # Fetch next bundle
827
827
+
plcbundle fetch -count 0 # Fetch all available
828
828
+
plcbundle clone <url> # Clone from remote
829
829
+
830
830
+
# Manage
831
831
+
plcbundle info # Show repository info
832
832
+
plcbundle info -bundle 42 # Show specific bundle
833
833
+
plcbundle rebuild # Rebuild index
834
834
+
plcbundle verify # Verify chain
835
835
+
836
836
+
# Export
837
837
+
plcbundle export -count 1000 # Export operations
838
838
+
plcbundle backfill > all.jsonl # Export everything
839
839
+
plcbundle mempool -export > mem.jsonl # Export mempool
840
840
+
841
841
+
# Serve
842
842
+
plcbundle serve # Basic HTTP server
843
843
+
plcbundle serve -sync -websocket # Full-featured server
844
844
+
845
845
+
# Utilities
846
846
+
plcbundle compare <url> # Compare with remote
847
847
+
plcbundle mempool # Check mempool status
848
848
+
plcbundle version # Show version
849
849
+
```
850
850
+
851
851
+
---
852
852
+
853
853
+
## Getting Help
854
854
+
855
855
+
**Command help:**
856
856
+
```bash
857
857
+
plcbundle fetch -h
858
858
+
```
+1630
docs/library.md
···
1
1
+
# Library Guide
2
2
+
3
3
+
A practical guide to using plcbundle as a Go library in your applications.
4
4
+
5
5
+
## Table of Contents
6
6
+
7
7
+
- [Getting Started](#getting-started)
8
8
+
- [Core Concepts](#core-concepts)
9
9
+
- [Common Patterns](#common-patterns)
10
10
+
- [Building Applications](#building-applications)
11
11
+
- [Advanced Usage](#advanced-usage)
12
12
+
- [Best Practices](#best-practices)
13
13
+
- [API Reference](#api-reference)
14
14
+
15
15
+
---
16
16
+
17
17
+
## Getting Started
18
18
+
19
19
+
### Installation
20
20
+
21
21
+
```bash
22
22
+
go get tangled.org/atscan.net/plcbundle
23
23
+
```
24
24
+
25
25
+
### Your First Program
26
26
+
27
27
+
Create a simple program to fetch and display bundle information:
28
28
+
29
29
+
```go
30
30
+
package main
31
31
+
32
32
+
import (
33
33
+
"context"
34
34
+
"log"
35
35
+
36
36
+
plcbundle "tangled.org/atscan.net/plcbundle"
37
37
+
)
38
38
+
39
39
+
func main() {
40
40
+
// Create a manager
41
41
+
mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
42
42
+
if err != nil {
43
43
+
log.Fatal(err)
44
44
+
}
45
45
+
defer mgr.Close()
46
46
+
47
47
+
// Get repository info
48
48
+
info := mgr.GetInfo()
49
49
+
log.Printf("Bundle directory: %s", info["bundle_dir"])
50
50
+
51
51
+
// Get index stats
52
52
+
index := mgr.GetIndex()
53
53
+
stats := index.GetStats()
54
54
+
log.Printf("Total bundles: %d", stats["bundle_count"])
55
55
+
}
56
56
+
```
57
57
+
58
58
+
Run it:
59
59
+
```bash
60
60
+
go run main.go
61
61
+
# 2025/01/15 10:30:00 Bundle directory: ./plc_data
62
62
+
# 2025/01/15 10:30:00 Total bundles: 0
63
63
+
```
64
64
+
65
65
+
### Fetching Your First Bundle
66
66
+
67
67
+
Let's fetch a bundle from the PLC directory:
68
68
+
69
69
+
```go
70
70
+
package main
71
71
+
72
72
+
import (
73
73
+
"context"
74
74
+
"log"
75
75
+
76
76
+
plcbundle "tangled.org/atscan.net/plcbundle"
77
77
+
)
78
78
+
79
79
+
func main() {
80
80
+
mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
81
81
+
if err != nil {
82
82
+
log.Fatal(err)
83
83
+
}
84
84
+
defer mgr.Close()
85
85
+
86
86
+
ctx := context.Background()
87
87
+
88
88
+
// Fetch next bundle
89
89
+
log.Println("Fetching bundle...")
90
90
+
bundle, err := mgr.FetchNext(ctx)
91
91
+
if err != nil {
92
92
+
log.Fatal(err)
93
93
+
}
94
94
+
95
95
+
log.Printf("✓ Fetched bundle %d", bundle.BundleNumber)
96
96
+
log.Printf(" Operations: %d", len(bundle.Operations))
97
97
+
log.Printf(" Unique DIDs: %d", bundle.DIDCount)
98
98
+
log.Printf(" Time range: %s to %s",
99
99
+
bundle.StartTime.Format("2006-01-02"),
100
100
+
bundle.EndTime.Format("2006-01-02"))
101
101
+
}
102
102
+
```
103
103
+
104
104
+
**What's happening here?**
105
105
+
106
106
+
1. `plcbundle.New()` creates a manager that handles all bundle operations
107
107
+
2. `FetchNext()` automatically:
108
108
+
- Fetches operations from PLC directory
109
109
+
- Creates a bundle when 10,000 operations are collected
110
110
+
- Saves the bundle to disk
111
111
+
- Updates the index
112
112
+
- Returns the bundle object
113
113
+
114
114
+
### Reading Bundles
115
115
+
116
116
+
Once you have bundles, you can load and read them:
117
117
+
118
118
+
```go
119
119
+
package main
120
120
+
121
121
+
import (
122
122
+
"context"
123
123
+
"log"
124
124
+
125
125
+
plcbundle "tangled.org/atscan.net/plcbundle"
126
126
+
)
127
127
+
128
128
+
func main() {
129
129
+
mgr, err := plcbundle.New("./plc_data", "")
130
130
+
if err != nil {
131
131
+
log.Fatal(err)
132
132
+
}
133
133
+
defer mgr.Close()
134
134
+
135
135
+
ctx := context.Background()
136
136
+
137
137
+
// Load bundle 1
138
138
+
bundle, err := mgr.Load(ctx, 1)
139
139
+
if err != nil {
140
140
+
log.Fatal(err)
141
141
+
}
142
142
+
143
143
+
log.Printf("Bundle %d loaded", bundle.BundleNumber)
144
144
+
145
145
+
// Iterate through operations
146
146
+
for i, op := range bundle.Operations {
147
147
+
if i >= 5 {
148
148
+
break // Just show first 5
149
149
+
}
150
150
+
log.Printf("%d. DID: %s, CID: %s", i+1, op.DID, op.CID)
151
151
+
}
152
152
+
}
153
153
+
```
154
154
+
155
155
+
---
156
156
+
157
157
+
## Core Concepts
158
158
+
159
159
+
### The Manager
160
160
+
161
161
+
The `Manager` is your main entry point. It handles:
162
162
+
- Bundle storage and retrieval
163
163
+
- Index management
164
164
+
- PLC directory synchronization
165
165
+
- Verification
166
166
+
- Mempool management
167
167
+
168
168
+
**Creating a manager:**
169
169
+
170
170
+
```go
171
171
+
// Simple creation
172
172
+
mgr, err := plcbundle.New("./bundles", "https://plc.directory")
173
173
+
174
174
+
// Custom configuration
175
175
+
config := plcbundle.DefaultConfig("./bundles")
176
176
+
config.VerifyOnLoad = true
177
177
+
config.AutoRebuild = true
178
178
+
179
179
+
plcClient := plcbundle.NewPLCClient("https://plc.directory")
180
180
+
mgr, err := plcbundle.NewManager(config, plcClient)
181
181
+
```
182
182
+
183
183
+
### Bundles
184
184
+
185
185
+
A bundle contains exactly 10,000 operations:
186
186
+
187
187
+
```go
188
188
+
type Bundle struct {
189
189
+
BundleNumber int // Sequential number (1, 2, 3...)
190
190
+
StartTime time.Time // First operation timestamp
191
191
+
EndTime time.Time // Last operation timestamp
192
192
+
Operations []plc.PLCOperation // The 10,000 operations
193
193
+
DIDCount int // Unique DIDs in bundle
194
194
+
Hash string // Chain hash (includes history)
195
195
+
ContentHash string // This bundle's content hash
196
196
+
Parent string // Previous bundle's chain hash
197
197
+
CompressedSize int64 // File size on disk
198
198
+
UncompressedSize int64 // Original JSONL size
199
199
+
}
200
200
+
```
201
201
+
202
202
+
### The Index
203
203
+
204
204
+
The index tracks all bundles and their metadata:
205
205
+
206
206
+
```go
207
207
+
index := mgr.GetIndex()
208
208
+
209
209
+
// Get all bundles
210
210
+
bundles := index.GetBundles()
211
211
+
for _, meta := range bundles {
212
212
+
log.Printf("Bundle %d: %s to %s",
213
213
+
meta.BundleNumber,
214
214
+
meta.StartTime.Format("2006-01-02"),
215
215
+
meta.EndTime.Format("2006-01-02"))
216
216
+
}
217
217
+
218
218
+
// Get specific bundle metadata
219
219
+
meta, err := index.GetBundle(42)
220
220
+
221
221
+
// Get last bundle
222
222
+
lastBundle := index.GetLastBundle()
223
223
+
```
224
224
+
225
225
+
### Operations
226
226
+
227
227
+
Each operation represents a DID PLC directory event:
228
228
+
229
229
+
```go
230
230
+
type PLCOperation struct {
231
231
+
DID string // The DID (did:plc:...)
232
232
+
Operation map[string]interface{} // The operation data
233
233
+
CID string // Content identifier
234
234
+
Nullified interface{} // nil, false, or CID string
235
235
+
CreatedAt time.Time // When it was created
236
236
+
RawJSON []byte // Original JSON bytes
237
237
+
}
238
238
+
239
239
+
// Check if operation was nullified
240
240
+
if op.IsNullified() {
241
241
+
log.Printf("Operation %s was nullified by %s", op.CID, op.GetNullifyingCID())
242
242
+
}
243
243
+
```
244
244
+
245
245
+
---
246
246
+
247
247
+
## Common Patterns
248
248
+
249
249
+
### Pattern 1: Transparent Sync Service
250
250
+
251
251
+
**Goal:** Keep a local PLC mirror continuously synchronized.
252
252
+
253
253
+
This is the most common use case - maintaining an up-to-date copy of the PLC directory.
254
254
+
255
255
+
```go
256
256
+
package main
257
257
+
258
258
+
import (
259
259
+
"context"
260
260
+
"log"
261
261
+
"os"
262
262
+
"os/signal"
263
263
+
"syscall"
264
264
+
"time"
265
265
+
266
266
+
plcbundle "tangled.org/atscan.net/plcbundle"
267
267
+
)
268
268
+
269
269
+
type SyncService struct {
270
270
+
mgr *plcbundle.Manager
271
271
+
interval time.Duration
272
272
+
stop chan struct{}
273
273
+
}
274
274
+
275
275
+
func NewSyncService(bundleDir string, interval time.Duration) (*SyncService, error) {
276
276
+
mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
277
277
+
if err != nil {
278
278
+
return nil, err
279
279
+
}
280
280
+
281
281
+
return &SyncService{
282
282
+
mgr: mgr,
283
283
+
interval: interval,
284
284
+
stop: make(chan struct{}),
285
285
+
}, nil
286
286
+
}
287
287
+
288
288
+
func (s *SyncService) Start() {
289
289
+
log.Println("Starting sync service...")
290
290
+
291
291
+
// Initial sync
292
292
+
s.sync()
293
293
+
294
294
+
// Periodic sync
295
295
+
ticker := time.NewTicker(s.interval)
296
296
+
defer ticker.Stop()
297
297
+
298
298
+
for {
299
299
+
select {
300
300
+
case <-ticker.C:
301
301
+
s.sync()
302
302
+
case <-s.stop:
303
303
+
log.Println("Sync service stopped")
304
304
+
return
305
305
+
}
306
306
+
}
307
307
+
}
308
308
+
309
309
+
func (s *SyncService) sync() {
310
310
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
311
311
+
defer cancel()
312
312
+
313
313
+
log.Println("Checking for new bundles...")
314
314
+
315
315
+
fetched := 0
316
316
+
for {
317
317
+
bundle, err := s.mgr.FetchNext(ctx)
318
318
+
if err != nil {
319
319
+
if isInsufficientOps(err) {
320
320
+
if fetched > 0 {
321
321
+
log.Printf("✓ Synced %d new bundles", fetched)
322
322
+
} else {
323
323
+
log.Println("✓ Up to date")
324
324
+
}
325
325
+
return
326
326
+
}
327
327
+
log.Printf("Error: %v", err)
328
328
+
return
329
329
+
}
330
330
+
331
331
+
fetched++
332
332
+
log.Printf("✓ Fetched bundle %d (%d ops, %d DIDs)",
333
333
+
bundle.BundleNumber, len(bundle.Operations), bundle.DIDCount)
334
334
+
}
335
335
+
}
336
336
+
337
337
+
func (s *SyncService) Stop() {
338
338
+
close(s.stop)
339
339
+
s.mgr.Close()
340
340
+
}
341
341
+
342
342
+
func isInsufficientOps(err error) bool {
343
343
+
return err != nil &&
344
344
+
(strings.Contains(err.Error(), "insufficient operations") ||
345
345
+
strings.Contains(err.Error(), "no more available"))
346
346
+
}
347
347
+
348
348
+
func main() {
349
349
+
service, err := NewSyncService("./plc_data", 5*time.Minute)
350
350
+
if err != nil {
351
351
+
log.Fatal(err)
352
352
+
}
353
353
+
354
354
+
// Start service in background
355
355
+
go service.Start()
356
356
+
357
357
+
// Wait for interrupt
358
358
+
sigChan := make(chan os.Signal, 1)
359
359
+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
360
360
+
<-sigChan
361
361
+
362
362
+
log.Println("Shutting down...")
363
363
+
service.Stop()
364
364
+
}
365
365
+
```
366
366
+
367
367
+
**Usage:**
368
368
+
```bash
369
369
+
go run main.go
370
370
+
# Starting sync service...
371
371
+
# Checking for new bundles...
372
372
+
# ✓ Fetched bundle 8548 (10000 ops, 8234 DIDs)
373
373
+
# ✓ Fetched bundle 8549 (10000 ops, 8156 DIDs)
374
374
+
# ✓ Up to date
375
375
+
# ... (repeats every 5 minutes)
376
376
+
```
377
377
+
378
378
+
### Pattern 2: Reading and Processing Operations
379
379
+
380
380
+
**Goal:** Process all historical operations for analysis.
381
381
+
382
382
+
```go
383
383
+
package main
384
384
+
385
385
+
import (
386
386
+
"context"
387
387
+
"log"
388
388
+
389
389
+
plcbundle "tangled.org/atscan.net/plcbundle"
390
390
+
)
391
391
+
392
392
+
type OperationProcessor struct {
393
393
+
mgr *plcbundle.Manager
394
394
+
}
395
395
+
396
396
+
func NewOperationProcessor(bundleDir string) (*OperationProcessor, error) {
397
397
+
mgr, err := plcbundle.New(bundleDir, "")
398
398
+
if err != nil {
399
399
+
return nil, err
400
400
+
}
401
401
+
402
402
+
return &OperationProcessor{mgr: mgr}, nil
403
403
+
}
404
404
+
405
405
+
func (p *OperationProcessor) ProcessAll() error {
406
406
+
ctx := context.Background()
407
407
+
408
408
+
index := p.mgr.GetIndex()
409
409
+
bundles := index.GetBundles()
410
410
+
411
411
+
log.Printf("Processing %d bundles...", len(bundles))
412
412
+
413
413
+
totalOps := 0
414
414
+
uniqueDIDs := make(map[string]bool)
415
415
+
416
416
+
for _, meta := range bundles {
417
417
+
// Load bundle
418
418
+
bundle, err := p.mgr.Load(ctx, meta.BundleNumber)
419
419
+
if err != nil {
420
420
+
return err
421
421
+
}
422
422
+
423
423
+
// Process operations
424
424
+
for _, op := range bundle.Operations {
425
425
+
totalOps++
426
426
+
uniqueDIDs[op.DID] = true
427
427
+
428
428
+
// Your processing logic here
429
429
+
p.processOperation(op)
430
430
+
}
431
431
+
432
432
+
if meta.BundleNumber % 100 == 0 {
433
433
+
log.Printf("Processed bundle %d...", meta.BundleNumber)
434
434
+
}
435
435
+
}
436
436
+
437
437
+
log.Printf("✓ Processed %d operations from %d unique DIDs",
438
438
+
totalOps, len(uniqueDIDs))
439
439
+
440
440
+
return nil
441
441
+
}
442
442
+
443
443
+
func (p *OperationProcessor) processOperation(op plcbundle.PLCOperation) {
444
444
+
// Example: Extract PDS endpoints
445
445
+
if services, ok := op.Operation["services"].(map[string]interface{}); ok {
446
446
+
if pds, ok := services["atproto_pds"].(map[string]interface{}); ok {
447
447
+
if endpoint, ok := pds["endpoint"].(string); ok {
448
448
+
log.Printf("DID %s uses PDS: %s", op.DID, endpoint)
449
449
+
}
450
450
+
}
451
451
+
}
452
452
+
}
453
453
+
454
454
+
func main() {
455
455
+
processor, err := NewOperationProcessor("./plc_data")
456
456
+
if err != nil {
457
457
+
log.Fatal(err)
458
458
+
}
459
459
+
460
460
+
if err := processor.ProcessAll(); err != nil {
461
461
+
log.Fatal(err)
462
462
+
}
463
463
+
}
464
464
+
```
465
465
+
466
466
+
### Pattern 3: Time-Based Queries
467
467
+
468
468
+
**Goal:** Export operations from a specific time period.
469
469
+
470
470
+
```go
471
471
+
package main
472
472
+
473
473
+
import (
474
474
+
"context"
475
475
+
"encoding/json"
476
476
+
"log"
477
477
+
"os"
478
478
+
"time"
479
479
+
480
480
+
plcbundle "tangled.org/atscan.net/plcbundle"
481
481
+
)
482
482
+
483
483
+
func exportOperationsSince(bundleDir string, since time.Time, limit int) error {
484
484
+
mgr, err := plcbundle.New(bundleDir, "")
485
485
+
if err != nil {
486
486
+
return err
487
487
+
}
488
488
+
defer mgr.Close()
489
489
+
490
490
+
ctx := context.Background()
491
491
+
492
492
+
// Export operations after timestamp
493
493
+
ops, err := mgr.Export(ctx, since, limit)
494
494
+
if err != nil {
495
495
+
return err
496
496
+
}
497
497
+
498
498
+
log.Printf("Exporting %d operations...", len(ops))
499
499
+
500
500
+
// Write as JSONL to stdout
501
501
+
encoder := json.NewEncoder(os.Stdout)
502
502
+
for _, op := range ops {
503
503
+
if err := encoder.Encode(op); err != nil {
504
504
+
return err
505
505
+
}
506
506
+
}
507
507
+
508
508
+
return nil
509
509
+
}
510
510
+
511
511
+
func main() {
512
512
+
// Export operations from the last 7 days
513
513
+
since := time.Now().AddDate(0, 0, -7)
514
514
+
515
515
+
if err := exportOperationsSince("./plc_data", since, 50000); err != nil {
516
516
+
log.Fatal(err)
517
517
+
}
518
518
+
}
519
519
+
```
520
520
+
521
521
+
**Output to file:**
522
522
+
```bash
523
523
+
go run main.go > last_7_days.jsonl
524
524
+
```
525
525
+
526
526
+
### Pattern 4: Verification Service
527
527
+
528
528
+
**Goal:** Periodically verify bundle integrity.
529
529
+
530
530
+
```go
531
531
+
package main
532
532
+
533
533
+
import (
534
534
+
"context"
535
535
+
"log"
536
536
+
"time"
537
537
+
538
538
+
plcbundle "tangled.org/atscan.net/plcbundle"
539
539
+
)
540
540
+
541
541
+
type VerificationService struct {
542
542
+
mgr *plcbundle.Manager
543
543
+
interval time.Duration
544
544
+
}
545
545
+
546
546
+
func NewVerificationService(bundleDir string, interval time.Duration) (*VerificationService, error) {
547
547
+
mgr, err := plcbundle.New(bundleDir, "")
548
548
+
if err != nil {
549
549
+
return nil, err
550
550
+
}
551
551
+
552
552
+
return &VerificationService{
553
553
+
mgr: mgr,
554
554
+
interval: interval,
555
555
+
}, nil
556
556
+
}
557
557
+
558
558
+
func (v *VerificationService) Start() {
559
559
+
ticker := time.NewTicker(v.interval)
560
560
+
defer ticker.Stop()
561
561
+
562
562
+
// Verify immediately on start
563
563
+
v.verify()
564
564
+
565
565
+
for range ticker.C {
566
566
+
v.verify()
567
567
+
}
568
568
+
}
569
569
+
570
570
+
func (v *VerificationService) verify() {
571
571
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
572
572
+
defer cancel()
573
573
+
574
574
+
log.Println("Starting chain verification...")
575
575
+
start := time.Now()
576
576
+
577
577
+
result, err := v.mgr.VerifyChain(ctx)
578
578
+
if err != nil {
579
579
+
log.Printf("❌ Verification error: %v", err)
580
580
+
return
581
581
+
}
582
582
+
583
583
+
elapsed := time.Since(start)
584
584
+
585
585
+
if result.Valid {
586
586
+
log.Printf("✅ Chain verified: %d bundles, took %s",
587
587
+
result.ChainLength, elapsed.Round(time.Second))
588
588
+
589
589
+
// Get head hash
590
590
+
index := v.mgr.GetIndex()
591
591
+
if last := index.GetLastBundle(); last != nil {
592
592
+
log.Printf(" Head hash: %s...", last.Hash[:16])
593
593
+
}
594
594
+
} else {
595
595
+
log.Printf("❌ Chain broken at bundle %d: %s",
596
596
+
result.BrokenAt, result.Error)
597
597
+
598
598
+
// Alert or take action
599
599
+
v.handleBrokenChain(result)
600
600
+
}
601
601
+
}
602
602
+
603
603
+
func (v *VerificationService) handleBrokenChain(result *plcbundle.ChainVerificationResult) {
604
604
+
// Send alert, trigger re-sync, etc.
605
605
+
log.Printf("⚠️ ALERT: Chain integrity compromised!")
606
606
+
// TODO: Implement your alerting logic
607
607
+
}
608
608
+
609
609
+
func main() {
610
610
+
service, err := NewVerificationService("./plc_data", 24*time.Hour)
611
611
+
if err != nil {
612
612
+
log.Fatal(err)
613
613
+
}
614
614
+
615
615
+
log.Println("Verification service started (daily checks)")
616
616
+
service.Start()
617
617
+
}
618
618
+
```
619
619
+
620
620
+
### Pattern 5: Custom HTTP API
621
621
+
622
622
+
**Goal:** Build a custom API on top of your bundle archive.
623
623
+
624
624
+
```go
625
625
+
package main
626
626
+
627
627
+
import (
628
628
+
"encoding/json"
629
629
+
"log"
630
630
+
"net/http"
631
631
+
"strconv"
632
632
+
633
633
+
plcbundle "tangled.org/atscan.net/plcbundle"
634
634
+
)
635
635
+
636
636
+
type API struct {
637
637
+
mgr *plcbundle.Manager
638
638
+
}
639
639
+
640
640
+
func NewAPI(bundleDir string) (*API, error) {
641
641
+
mgr, err := plcbundle.New(bundleDir, "")
642
642
+
if err != nil {
643
643
+
return nil, err
644
644
+
}
645
645
+
646
646
+
return &API{mgr: mgr}, nil
647
647
+
}
648
648
+
649
649
+
func (api *API) handleStats(w http.ResponseWriter, r *http.Request) {
650
650
+
index := api.mgr.GetIndex()
651
651
+
stats := index.GetStats()
652
652
+
653
653
+
response := map[string]interface{}{
654
654
+
"bundles": stats["bundle_count"],
655
655
+
"first": stats["first_bundle"],
656
656
+
"last": stats["last_bundle"],
657
657
+
"total_size": stats["total_size"],
658
658
+
"start_time": stats["start_time"],
659
659
+
"end_time": stats["end_time"],
660
660
+
"updated_at": stats["updated_at"],
661
661
+
}
662
662
+
663
663
+
w.Header().Set("Content-Type", "application/json")
664
664
+
json.NewEncoder(w).Encode(response)
665
665
+
}
666
666
+
667
667
+
func (api *API) handleOperations(w http.ResponseWriter, r *http.Request) {
668
668
+
bundleNumStr := r.URL.Query().Get("bundle")
669
669
+
if bundleNumStr == "" {
670
670
+
http.Error(w, "bundle parameter required", http.StatusBadRequest)
671
671
+
return
672
672
+
}
673
673
+
674
674
+
bundleNum, err := strconv.Atoi(bundleNumStr)
675
675
+
if err != nil {
676
676
+
http.Error(w, "invalid bundle number", http.StatusBadRequest)
677
677
+
return
678
678
+
}
679
679
+
680
680
+
ctx := r.Context()
681
681
+
bundle, err := api.mgr.Load(ctx, bundleNum)
682
682
+
if err != nil {
683
683
+
http.Error(w, err.Error(), http.StatusNotFound)
684
684
+
return
685
685
+
}
686
686
+
687
687
+
w.Header().Set("Content-Type", "application/x-ndjson")
688
688
+
encoder := json.NewEncoder(w)
689
689
+
for _, op := range bundle.Operations {
690
690
+
encoder.Encode(op)
691
691
+
}
692
692
+
}
693
693
+
694
694
+
func (api *API) handleDID(w http.ResponseWriter, r *http.Request) {
695
695
+
did := r.URL.Query().Get("did")
696
696
+
if did == "" {
697
697
+
http.Error(w, "did parameter required", http.StatusBadRequest)
698
698
+
return
699
699
+
}
700
700
+
701
701
+
ctx := r.Context()
702
702
+
703
703
+
// Search through bundles for this DID
704
704
+
var operations []plcbundle.PLCOperation
705
705
+
706
706
+
index := api.mgr.GetIndex()
707
707
+
bundles := index.GetBundles()
708
708
+
709
709
+
for _, meta := range bundles {
710
710
+
bundle, err := api.mgr.Load(ctx, meta.BundleNumber)
711
711
+
if err != nil {
712
712
+
continue
713
713
+
}
714
714
+
715
715
+
for _, op := range bundle.Operations {
716
716
+
if op.DID == did {
717
717
+
operations = append(operations, op)
718
718
+
}
719
719
+
}
720
720
+
}
721
721
+
722
722
+
w.Header().Set("Content-Type", "application/json")
723
723
+
json.NewEncoder(w).Encode(map[string]interface{}{
724
724
+
"did": did,
725
725
+
"operations": operations,
726
726
+
"count": len(operations),
727
727
+
})
728
728
+
}
729
729
+
730
730
+
func main() {
731
731
+
api, err := NewAPI("./plc_data")
732
732
+
if err != nil {
733
733
+
log.Fatal(err)
734
734
+
}
735
735
+
736
736
+
http.HandleFunc("/stats", api.handleStats)
737
737
+
http.HandleFunc("/operations", api.handleOperations)
738
738
+
http.HandleFunc("/did", api.handleDID)
739
739
+
740
740
+
log.Println("API listening on :8080")
741
741
+
log.Fatal(http.ListenAndServe(":8080", nil))
742
742
+
}
743
743
+
```
744
744
+
745
745
+
**Usage:**
746
746
+
```bash
747
747
+
# Get stats
748
748
+
curl http://localhost:8080/stats
749
749
+
750
750
+
# Get operations from bundle 1
751
751
+
curl http://localhost:8080/operations?bundle=1
752
752
+
753
753
+
# Get all operations for a DID
754
754
+
curl http://localhost:8080/did?did=did:plc:example123
755
755
+
```
756
756
+
757
757
+
---
758
758
+
759
759
+
## Building Applications
760
760
+
761
761
+
### Application 1: PDS Discovery Tool
762
762
+
763
763
+
Find all PDS endpoints in the network:
764
764
+
765
765
+
```go
766
766
+
package main
767
767
+
768
768
+
import (
769
769
+
"context"
770
770
+
"fmt"
771
771
+
"log"
772
772
+
773
773
+
plcbundle "tangled.org/atscan.net/plcbundle"
774
774
+
)
775
775
+
776
776
+
type PDSTracker struct {
777
777
+
mgr *plcbundle.Manager
778
778
+
endpoints map[string]int // endpoint -> count
779
779
+
}
780
780
+
781
781
+
func NewPDSTracker(bundleDir string) (*PDSTracker, error) {
782
782
+
mgr, err := plcbundle.New(bundleDir, "")
783
783
+
if err != nil {
784
784
+
return nil, err
785
785
+
}
786
786
+
787
787
+
return &PDSTracker{
788
788
+
mgr: mgr,
789
789
+
endpoints: make(map[string]int),
790
790
+
}, nil
791
791
+
}
792
792
+
793
793
+
func (pt *PDSTracker) Scan() error {
794
794
+
ctx := context.Background()
795
795
+
796
796
+
index := pt.mgr.GetIndex()
797
797
+
bundles := index.GetBundles()
798
798
+
799
799
+
log.Printf("Scanning %d bundles for PDS endpoints...", len(bundles))
800
800
+
801
801
+
for _, meta := range bundles {
802
802
+
bundle, err := pt.mgr.Load(ctx, meta.BundleNumber)
803
803
+
if err != nil {
804
804
+
return err
805
805
+
}
806
806
+
807
807
+
for _, op := range bundle.Operations {
808
808
+
if endpoint := pt.extractPDS(op); endpoint != "" {
809
809
+
pt.endpoints[endpoint]++
810
810
+
}
811
811
+
}
812
812
+
}
813
813
+
814
814
+
return nil
815
815
+
}
816
816
+
817
817
+
func (pt *PDSTracker) extractPDS(op plcbundle.PLCOperation) string {
818
818
+
services, ok := op.Operation["services"].(map[string]interface{})
819
819
+
if !ok {
820
820
+
return ""
821
821
+
}
822
822
+
823
823
+
pds, ok := services["atproto_pds"].(map[string]interface{})
824
824
+
if !ok {
825
825
+
return ""
826
826
+
}
827
827
+
828
828
+
endpoint, ok := pds["endpoint"].(string)
829
829
+
if !ok {
830
830
+
return ""
831
831
+
}
832
832
+
833
833
+
return endpoint
834
834
+
}
835
835
+
836
836
+
func (pt *PDSTracker) PrintResults() {
837
837
+
log.Printf("\nFound %d unique PDS endpoints:\n", len(pt.endpoints))
838
838
+
839
839
+
// Sort by count
840
840
+
type endpointCount struct {
841
841
+
endpoint string
842
842
+
count int
843
843
+
}
844
844
+
845
845
+
var sorted []endpointCount
846
846
+
for endpoint, count := range pt.endpoints {
847
847
+
sorted = append(sorted, endpointCount{endpoint, count})
848
848
+
}
849
849
+
850
850
+
sort.Slice(sorted, func(i, j int) bool {
851
851
+
return sorted[i].count > sorted[j].count
852
852
+
})
853
853
+
854
854
+
// Print top 20
855
855
+
for i, ec := range sorted {
856
856
+
if i >= 20 {
857
857
+
break
858
858
+
}
859
859
+
fmt.Printf("%3d. %s (%d DIDs)\n", i+1, ec.endpoint, ec.count)
860
860
+
}
861
861
+
}
862
862
+
863
863
+
func main() {
864
864
+
tracker, err := NewPDSTracker("./plc_data")
865
865
+
if err != nil {
866
866
+
log.Fatal(err)
867
867
+
}
868
868
+
869
869
+
if err := tracker.Scan(); err != nil {
870
870
+
log.Fatal(err)
871
871
+
}
872
872
+
873
873
+
tracker.PrintResults()
874
874
+
}
875
875
+
```
876
876
+
877
877
+
### Application 2: DID History Viewer
878
878
+
879
879
+
View the complete history of a DID:
880
880
+
881
881
+
```go
882
882
+
package main
883
883
+
884
884
+
import (
885
885
+
"context"
886
886
+
"encoding/json"
887
887
+
"fmt"
888
888
+
"log"
889
889
+
"os"
890
890
+
891
891
+
plcbundle "tangled.org/atscan.net/plcbundle"
892
892
+
)
893
893
+
894
894
+
type DIDHistory struct {
895
895
+
DID string `json:"did"`
896
896
+
Operations []plcbundle.PLCOperation `json:"operations"`
897
897
+
FirstSeen time.Time `json:"first_seen"`
898
898
+
LastSeen time.Time `json:"last_seen"`
899
899
+
OpCount int `json:"operation_count"`
900
900
+
}
901
901
+
902
902
+
func getDIDHistory(bundleDir, did string) (*DIDHistory, error) {
903
903
+
mgr, err := plcbundle.New(bundleDir, "")
904
904
+
if err != nil {
905
905
+
return nil, err
906
906
+
}
907
907
+
defer mgr.Close()
908
908
+
909
909
+
ctx := context.Background()
910
910
+
911
911
+
history := &DIDHistory{
912
912
+
DID: did,
913
913
+
Operations: make([]plcbundle.PLCOperation, 0),
914
914
+
}
915
915
+
916
916
+
index := mgr.GetIndex()
917
917
+
bundles := index.GetBundles()
918
918
+
919
919
+
log.Printf("Searching for DID %s...", did)
920
920
+
921
921
+
for _, meta := range bundles {
922
922
+
bundle, err := mgr.Load(ctx, meta.BundleNumber)
923
923
+
if err != nil {
924
924
+
continue
925
925
+
}
926
926
+
927
927
+
for _, op := range bundle.Operations {
928
928
+
if op.DID == did {
929
929
+
history.Operations = append(history.Operations, op)
930
930
+
}
931
931
+
}
932
932
+
}
933
933
+
934
934
+
if len(history.Operations) == 0 {
935
935
+
return nil, fmt.Errorf("DID not found")
936
936
+
}
937
937
+
938
938
+
// Set timestamps
939
939
+
history.FirstSeen = history.Operations[0].CreatedAt
940
940
+
history.LastSeen = history.Operations[len(history.Operations)-1].CreatedAt
941
941
+
history.OpCount = len(history.Operations)
942
942
+
943
943
+
return history, nil
944
944
+
}
945
945
+
946
946
+
func main() {
947
947
+
if len(os.Args) < 2 {
948
948
+
log.Fatal("Usage: did-history <did>")
949
949
+
}
950
950
+
951
951
+
did := os.Args[1]
952
952
+
953
953
+
history, err := getDIDHistory("./plc_data", did)
954
954
+
if err != nil {
955
955
+
log.Fatal(err)
956
956
+
}
957
957
+
958
958
+
// Print as JSON
959
959
+
encoder := json.NewEncoder(os.Stdout)
960
960
+
encoder.SetIndent("", " ")
961
961
+
encoder.Encode(history)
962
962
+
}
963
963
+
```
964
964
+
965
965
+
### Application 3: Real-time Monitor
966
966
+
967
967
+
Monitor new operations as they arrive:
968
968
+
969
969
+
```go
970
970
+
package main
971
971
+
972
972
+
import (
973
973
+
"context"
974
974
+
"log"
975
975
+
"time"
976
976
+
977
977
+
plcbundle "tangled.org/atscan.net/plcbundle"
978
978
+
)
979
979
+
980
980
+
type Monitor struct {
981
981
+
mgr *plcbundle.Manager
982
982
+
lastSeen int // Last bundle number processed
983
983
+
pollInterval time.Duration
984
984
+
}
985
985
+
986
986
+
func NewMonitor(bundleDir string, pollInterval time.Duration) (*Monitor, error) {
987
987
+
mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
988
988
+
if err != nil {
989
989
+
return nil, err
990
990
+
}
991
991
+
992
992
+
// Get current position
993
993
+
index := mgr.GetIndex()
994
994
+
lastBundle := index.GetLastBundle()
995
995
+
lastSeen := 0
996
996
+
if lastBundle != nil {
997
997
+
lastSeen = lastBundle.BundleNumber
998
998
+
}
999
999
+
1000
1000
+
return &Monitor{
1001
1001
+
mgr: mgr,
1002
1002
+
lastSeen: lastSeen,
1003
1003
+
pollInterval: pollInterval,
1004
1004
+
}, nil
1005
1005
+
}
1006
1006
+
1007
1007
+
func (m *Monitor) Start() {
1008
1008
+
log.Println("Monitor started, watching for new bundles...")
1009
1009
+
1010
1010
+
ticker := time.NewTicker(m.pollInterval)
1011
1011
+
defer ticker.Stop()
1012
1012
+
1013
1013
+
for range ticker.C {
1014
1014
+
m.check()
1015
1015
+
}
1016
1016
+
}
1017
1017
+
1018
1018
+
func (m *Monitor) check() {
1019
1019
+
ctx := context.Background()
1020
1020
+
1021
1021
+
// Try to fetch next bundle
1022
1022
+
bundle, err := m.mgr.FetchNext(ctx)
1023
1023
+
if err != nil {
1024
1024
+
// Not an error if no new bundle available
1025
1025
+
return
1026
1026
+
}
1027
1027
+
1028
1028
+
// New bundle!
1029
1029
+
log.Printf("🔔 New bundle: %d", bundle.BundleNumber)
1030
1030
+
log.Printf(" Operations: %d", len(bundle.Operations))
1031
1031
+
log.Printf(" DIDs: %d", bundle.DIDCount)
1032
1032
+
log.Printf(" Time: %s", bundle.EndTime.Format("2006-01-02 15:04:05"))
1033
1033
+
1034
1034
+
// Process new operations
1035
1035
+
m.processNewOperations(bundle)
1036
1036
+
1037
1037
+
m.lastSeen = bundle.BundleNumber
1038
1038
+
}
1039
1039
+
1040
1040
+
func (m *Monitor) processNewOperations(bundle *plcbundle.Bundle) {
1041
1041
+
for _, op := range bundle.Operations {
1042
1042
+
// Check for interesting operations
1043
1043
+
if op.IsNullified() {
1044
1044
+
log.Printf(" ⚠️ Nullified: %s", op.DID)
1045
1045
+
}
1046
1046
+
1047
1047
+
// Check for new DIDs (operation type "create")
1048
1048
+
if opType, ok := op.Operation["type"].(string); ok && opType == "create" {
1049
1049
+
log.Printf(" ➕ New DID: %s", op.DID)
1050
1050
+
}
1051
1051
+
}
1052
1052
+
}
1053
1053
+
1054
1054
+
func main() {
1055
1055
+
monitor, err := NewMonitor("./plc_data", 30*time.Second)
1056
1056
+
if err != nil {
1057
1057
+
log.Fatal(err)
1058
1058
+
}
1059
1059
+
1060
1060
+
monitor.Start()
1061
1061
+
}
1062
1062
+
```
1063
1063
+
1064
1064
+
---
1065
1065
+
1066
1066
+
## Advanced Usage
1067
1067
+
1068
1068
+
### Custom Configuration
1069
1069
+
1070
1070
+
Full control over bundle manager behavior:
1071
1071
+
1072
1072
+
```go
1073
1073
+
package main
1074
1074
+
1075
1075
+
import (
1076
1076
+
"log"
1077
1077
+
"runtime"
1078
1078
+
"time"
1079
1079
+
1080
1080
+
"tangled.org/atscan.net/plcbundle/bundle"
1081
1081
+
"tangled.org/atscan.net/plcbundle/plc"
1082
1082
+
plcbundle "tangled.org/atscan.net/plcbundle"
1083
1083
+
)
1084
1084
+
1085
1085
+
func main() {
1086
1086
+
// Custom configuration
1087
1087
+
config := &bundle.Config{
1088
1088
+
BundleDir: "./my_bundles",
1089
1089
+
VerifyOnLoad: true, // Verify hashes when loading
1090
1090
+
AutoRebuild: true, // Auto-rebuild index if needed
1091
1091
+
RebuildWorkers: runtime.NumCPU(), // Parallel workers for rebuild
1092
1092
+
Logger: &MyCustomLogger{}, // Custom logger
1093
1093
+
1094
1094
+
// Progress callback for rebuild
1095
1095
+
RebuildProgress: func(current, total int) {
1096
1096
+
if current%100 == 0 {
1097
1097
+
log.Printf("Rebuild: %d/%d (%.1f%%)",
1098
1098
+
current, total, float64(current)/float64(total)*100)
1099
1099
+
}
1100
1100
+
},
1101
1101
+
}
1102
1102
+
1103
1103
+
// Custom PLC client with rate limiting
1104
1104
+
plcClient := plc.NewClient("https://plc.directory",
1105
1105
+
plc.WithRateLimit(60, time.Minute), // 60 req/min
1106
1106
+
plc.WithTimeout(30*time.Second), // 30s timeout
1107
1107
+
plc.WithLogger(&MyCustomLogger{}), // Custom logger
1108
1108
+
)
1109
1109
+
1110
1110
+
// Create manager
1111
1111
+
mgr, err := bundle.NewManager(config, plcClient)
1112
1112
+
if err != nil {
1113
1113
+
log.Fatal(err)
1114
1114
+
}
1115
1115
+
defer mgr.Close()
1116
1116
+
1117
1117
+
log.Println("Manager created with custom configuration")
1118
1118
+
}
1119
1119
+
1120
1120
+
// Custom logger implementation
1121
1121
+
type MyCustomLogger struct{}
1122
1122
+
1123
1123
+
func (l *MyCustomLogger) Printf(format string, v ...interface{}) {
1124
1124
+
// Add custom formatting, filtering, etc.
1125
1125
+
log.Printf("[PLCBUNDLE] "+format, v...)
1126
1126
+
}
1127
1127
+
1128
1128
+
func (l *MyCustomLogger) Println(v ...interface{}) {
1129
1129
+
log.Println(append([]interface{}{"[PLCBUNDLE]"}, v...)...)
1130
1130
+
}
1131
1131
+
```
1132
1132
+
1133
1133
+
### Streaming Data
1134
1134
+
1135
1135
+
Stream bundle data without loading everything into memory:
1136
1136
+
1137
1137
+
```go
1138
1138
+
package main
1139
1139
+
1140
1140
+
import (
1141
1141
+
"bufio"
1142
1142
+
"context"
1143
1143
+
"encoding/json"
1144
1144
+
"io"
1145
1145
+
"log"
1146
1146
+
1147
1147
+
plcbundle "tangled.org/atscan.net/plcbundle"
1148
1148
+
)
1149
1149
+
1150
1150
+
func streamBundle(mgr *plcbundle.Manager, bundleNumber int) error {
1151
1151
+
ctx := context.Background()
1152
1152
+
1153
1153
+
// Get decompressed stream
1154
1154
+
reader, err := mgr.StreamDecompressed(ctx, bundleNumber)
1155
1155
+
if err != nil {
1156
1156
+
return err
1157
1157
+
}
1158
1158
+
defer reader.Close()
1159
1159
+
1160
1160
+
// Read line by line (JSONL)
1161
1161
+
scanner := bufio.NewScanner(reader)
1162
1162
+
1163
1163
+
// Set buffer size for large lines
1164
1164
+
buf := make([]byte, 0, 64*1024)
1165
1165
+
scanner.Buffer(buf, 1024*1024)
1166
1166
+
1167
1167
+
lineNum := 0
1168
1168
+
for scanner.Scan() {
1169
1169
+
lineNum++
1170
1170
+
1171
1171
+
var op plcbundle.PLCOperation
1172
1172
+
if err := json.Unmarshal(scanner.Bytes(), &op); err != nil {
1173
1173
+
log.Printf("Warning: failed to parse line %d: %v", lineNum, err)
1174
1174
+
continue
1175
1175
+
}
1176
1176
+
1177
1177
+
// Process operation without storing all in memory
1178
1178
+
processOperation(op)
1179
1179
+
}
1180
1180
+
1181
1181
+
return scanner.Err()
1182
1182
+
}
1183
1183
+
1184
1184
+
func processOperation(op plcbundle.PLCOperation) {
1185
1185
+
// Your processing logic
1186
1186
+
log.Printf("Processing: %s", op.DID)
1187
1187
+
}
1188
1188
+
1189
1189
+
func main() {
1190
1190
+
mgr, err := plcbundle.New("./plc_data", "")
1191
1191
+
if err != nil {
1192
1192
+
log.Fatal(err)
1193
1193
+
}
1194
1194
+
defer mgr.Close()
1195
1195
+
1196
1196
+
// Stream bundle 1
1197
1197
+
if err := streamBundle(mgr, 1); err != nil {
1198
1198
+
log.Fatal(err)
1199
1199
+
}
1200
1200
+
}
1201
1201
+
```
1202
1202
+
1203
1203
+
### Parallel Processing
1204
1204
+
1205
1205
+
Process multiple bundles concurrently:
1206
1206
+
1207
1207
+
```go
1208
1208
+
package main
1209
1209
+
1210
1210
+
import (
1211
1211
+
"context"
1212
1212
+
"log"
1213
1213
+
"sync"
1214
1214
+
1215
1215
+
plcbundle "tangled.org/atscan.net/plcbundle"
1216
1216
+
)
1217
1217
+
1218
1218
+
func processParallel(mgr *plcbundle.Manager, workers int) error {
1219
1219
+
ctx := context.Background()
1220
1220
+
1221
1221
+
index := mgr.GetIndex()
1222
1222
+
bundles := index.GetBundles()
1223
1223
+
1224
1224
+
// Create job channel
1225
1225
+
jobs := make(chan int, len(bundles))
1226
1226
+
results := make(chan error, len(bundles))
1227
1227
+
1228
1228
+
// Start workers
1229
1229
+
var wg sync.WaitGroup
1230
1230
+
for w := 0; w < workers; w++ {
1231
1231
+
wg.Add(1)
1232
1232
+
go func() {
1233
1233
+
defer wg.Done()
1234
1234
+
for bundleNum := range jobs {
1235
1235
+
if err := processBundle(ctx, mgr, bundleNum); err != nil {
1236
1236
+
results <- err
1237
1237
+
} else {
1238
1238
+
results <- nil
1239
1239
+
}
1240
1240
+
}
1241
1241
+
}()
1242
1242
+
}
1243
1243
+
1244
1244
+
// Send jobs
1245
1245
+
for _, meta := range bundles {
1246
1246
+
jobs <- meta.BundleNumber
1247
1247
+
}
1248
1248
+
close(jobs)
1249
1249
+
1250
1250
+
// Wait for completion
1251
1251
+
go func() {
1252
1252
+
wg.Wait()
1253
1253
+
close(results)
1254
1254
+
}()
1255
1255
+
1256
1256
+
// Collect results
1257
1257
+
errors := 0
1258
1258
+
for err := range results {
1259
1259
+
if err != nil {
1260
1260
+
log.Printf("Error: %v", err)
1261
1261
+
errors++
1262
1262
+
}
1263
1263
+
}
1264
1264
+
1265
1265
+
if errors > 0 {
1266
1266
+
return fmt.Errorf("%d bundles failed processing", errors)
1267
1267
+
}
1268
1268
+
1269
1269
+
return nil
1270
1270
+
}
1271
1271
+
1272
1272
+
func processBundle(ctx context.Context, mgr *plcbundle.Manager, bundleNum int) error {
1273
1273
+
bundle, err := mgr.Load(ctx, bundleNum)
1274
1274
+
if err != nil {
1275
1275
+
return err
1276
1276
+
}
1277
1277
+
1278
1278
+
// Process operations
1279
1279
+
for _, op := range bundle.Operations {
1280
1280
+
// Your logic here
1281
1281
+
_ = op
1282
1282
+
}
1283
1283
+
1284
1284
+
log.Printf("Processed bundle %d", bundleNum)
1285
1285
+
return nil
1286
1286
+
}
1287
1287
+
1288
1288
+
func main() {
1289
1289
+
mgr, err := plcbundle.New("./plc_data", "")
1290
1290
+
if err != nil {
1291
1291
+
log.Fatal(err)
1292
1292
+
}
1293
1293
+
defer mgr.Close()
1294
1294
+
1295
1295
+
// Process with 8 workers
1296
1296
+
if err := processParallel(mgr, 8); err != nil {
1297
1297
+
log.Fatal(err)
1298
1298
+
}
1299
1299
+
}
1300
1300
+
```
1301
1301
+
1302
1302
+
### Working with Mempool
1303
1303
+
1304
1304
+
Access operations before they're bundled:
1305
1305
+
1306
1306
+
```go
1307
1307
+
package main
1308
1308
+
1309
1309
+
import (
1310
1310
+
"log"
1311
1311
+
1312
1312
+
plcbundle "tangled.org/atscan.net/plcbundle"
1313
1313
+
)
1314
1314
+
1315
1315
+
func main() {
1316
1316
+
mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
1317
1317
+
if err != nil {
1318
1318
+
log.Fatal(err)
1319
1319
+
}
1320
1320
+
defer mgr.Close()
1321
1321
+
1322
1322
+
// Get mempool stats
1323
1323
+
stats := mgr.GetMempoolStats()
1324
1324
+
1325
1325
+
count := stats["count"].(int)
1326
1326
+
targetBundle := stats["target_bundle"].(int)
1327
1327
+
canCreate := stats["can_create_bundle"].(bool)
1328
1328
+
1329
1329
+
log.Printf("Mempool status:")
1330
1330
+
log.Printf(" Target bundle: %d", targetBundle)
1331
1331
+
log.Printf(" Operations: %d/%d", count, plcbundle.BUNDLE_SIZE)
1332
1332
+
log.Printf(" Ready: %v", canCreate)
1333
1333
+
1334
1334
+
if count > 0 {
1335
1335
+
// Get mempool operations
1336
1336
+
ops, err := mgr.GetMempoolOperations()
1337
1337
+
if err != nil {
1338
1338
+
log.Fatal(err)
1339
1339
+
}
1340
1340
+
1341
1341
+
log.Printf("Latest unbundled operations:")
1342
1342
+
for i, op := range ops {
1343
1343
+
if i >= 5 {
1344
1344
+
break
1345
1345
+
}
1346
1346
+
log.Printf(" %d. %s (%s)", i+1, op.DID, op.CreatedAt.Format("15:04:05"))
1347
1347
+
}
1348
1348
+
}
1349
1349
+
1350
1350
+
// Validate chronological order
1351
1351
+
if err := mgr.ValidateMempool(); err != nil {
1352
1352
+
log.Printf("⚠️ Mempool validation failed: %v", err)
1353
1353
+
} else {
1354
1354
+
log.Println("✓ Mempool validated")
1355
1355
+
}
1356
1356
+
}
1357
1357
+
```
1358
1358
+
1359
1359
+
---
1360
1360
+
1361
1361
+
## Best Practices
1362
1362
+
1363
1363
+
### 1. Always Close the Manager
1364
1364
+
1365
1365
+
Use `defer` to ensure cleanup:
1366
1366
+
1367
1367
+
```go
1368
1368
+
mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
1369
1369
+
if err != nil {
1370
1370
+
return err
1371
1371
+
}
1372
1372
+
defer mgr.Close() // Always close!
1373
1373
+
```
1374
1374
+
1375
1375
+
### 2. Handle Context Cancellation
1376
1376
+
1377
1377
+
Support graceful shutdown:
1378
1378
+
1379
1379
+
```go
1380
1380
+
ctx, cancel := context.WithCancel(context.Background())
1381
1381
+
defer cancel()
1382
1382
+
1383
1383
+
// Listen for interrupt
1384
1384
+
sigChan := make(chan os.Signal, 1)
1385
1385
+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
1386
1386
+
1387
1387
+
go func() {
1388
1388
+
<-sigChan
1389
1389
+
log.Println("Interrupt received, stopping...")
1390
1390
+
cancel()
1391
1391
+
}()
1392
1392
+
1393
1393
+
// Use context in operations
1394
1394
+
bundle, err := mgr.FetchNext(ctx)
1395
1395
+
if err == context.Canceled {
1396
1396
+
log.Println("Operation cancelled gracefully")
1397
1397
+
return nil
1398
1398
+
}
1399
1399
+
```
1400
1400
+
1401
1401
+
### 3. Check Errors Properly
1402
1402
+
1403
1403
+
Distinguish between different error types:
1404
1404
+
1405
1405
+
```go
1406
1406
+
bundle, err := mgr.FetchNext(ctx)
1407
1407
+
if err != nil {
1408
1408
+
// Check if it's just "caught up"
1409
1409
+
if strings.Contains(err.Error(), "insufficient operations") {
1410
1410
+
log.Println("No new bundles available (caught up)")
1411
1411
+
return nil
1412
1412
+
}
1413
1413
+
1414
1414
+
// Real error
1415
1415
+
return fmt.Errorf("fetch failed: %w", err)
1416
1416
+
}
1417
1417
+
```
1418
1418
+
1419
1419
+
### 4. Use Streaming for Large Datasets
1420
1420
+
1421
1421
+
Don't load everything into memory:
1422
1422
+
1423
1423
+
```go
1424
1424
+
// ❌ Bad: Loads all operations into memory
1425
1425
+
index := mgr.GetIndex()
1426
1426
+
var allOps []plcbundle.PLCOperation
1427
1427
+
for _, meta := range index.GetBundles() {
1428
1428
+
bundle, _ := mgr.Load(ctx, meta.BundleNumber)
1429
1429
+
allOps = append(allOps, bundle.Operations...)
1430
1430
+
}
1431
1431
+
1432
1432
+
// ✅ Good: Process one bundle at a time
1433
1433
+
for _, meta := range index.GetBundles() {
1434
1434
+
bundle, _ := mgr.Load(ctx, meta.BundleNumber)
1435
1435
+
for _, op := range bundle.Operations {
1436
1436
+
processOperation(op)
1437
1437
+
}
1438
1438
+
}
1439
1439
+
```
1440
1440
+
1441
1441
+
### 5. Enable Verification in Production
1442
1442
+
1443
1443
+
```go
1444
1444
+
config := plcbundle.DefaultConfig("./plc_data")
1445
1445
+
config.VerifyOnLoad = true // Verify hashes when loading
1446
1446
+
1447
1447
+
mgr, err := plcbundle.NewManager(config, plcClient)
1448
1448
+
```
1449
1449
+
1450
1450
+
### 6. Log Appropriately
1451
1451
+
1452
1452
+
Implement custom logger for production:
1453
1453
+
1454
1454
+
```go
1455
1455
+
type ProductionLogger struct {
1456
1456
+
logger *zap.Logger
1457
1457
+
}
1458
1458
+
1459
1459
+
func (l *ProductionLogger) Printf(format string, v ...interface{}) {
1460
1460
+
l.logger.Sugar().Infof(format, v...)
1461
1461
+
}
1462
1462
+
1463
1463
+
func (l *ProductionLogger) Println(v ...interface{}) {
1464
1464
+
l.logger.Sugar().Info(v...)
1465
1465
+
}
1466
1466
+
```
1467
1467
+
1468
1468
+
### 7. Handle Rate Limits
1469
1469
+
1470
1470
+
Configure PLC client appropriately:
1471
1471
+
1472
1472
+
```go
1473
1473
+
// Production: Be conservative
1474
1474
+
plcClient := plc.NewClient("https://plc.directory",
1475
1475
+
plc.WithRateLimit(60, time.Minute), // 60 req/min max
1476
1476
+
plc.WithTimeout(60*time.Second),
1477
1477
+
)
1478
1478
+
1479
1479
+
// Development: Can be more aggressive (but respectful)
1480
1480
+
plcClient := plc.NewClient("https://plc.directory",
1481
1481
+
plc.WithRateLimit(90, time.Minute),
1482
1482
+
plc.WithTimeout(30*time.Second),
1483
1483
+
)
1484
1484
+
```
1485
1485
+
1486
1486
+
---
1487
1487
+
1488
1488
+
## API Reference
1489
1489
+
1490
1490
+
### Manager Methods
1491
1491
+
1492
1492
+
```go
1493
1493
+
// Creation
1494
1494
+
New(bundleDir, plcURL string) (*Manager, error)
1495
1495
+
NewManager(config *Config, plcClient *PLCClient) (*Manager, error)
1496
1496
+
1497
1497
+
// Lifecycle
1498
1498
+
Close()
1499
1499
+
1500
1500
+
// Fetching
1501
1501
+
FetchNext(ctx) (*Bundle, error)
1502
1502
+
1503
1503
+
// Loading
1504
1504
+
Load(ctx, bundleNumber int) (*Bundle, error)
1505
1505
+
1506
1506
+
// Verification
1507
1507
+
Verify(ctx, bundleNumber int) (*VerificationResult, error)
1508
1508
+
VerifyChain(ctx) (*ChainVerificationResult, error)
1509
1509
+
1510
1510
+
// Exporting
1511
1511
+
Export(ctx, afterTime time.Time, count int) ([]PLCOperation, error)
1512
1512
+
1513
1513
+
// Streaming
1514
1514
+
StreamRaw(ctx, bundleNumber int) (io.ReadCloser, error)
1515
1515
+
StreamDecompressed(ctx, bundleNumber int) (io.ReadCloser, error)
1516
1516
+
1517
1517
+
// Index
1518
1518
+
GetIndex() *Index
1519
1519
+
ScanBundle(path string, bundleNumber int) (*BundleMetadata, error)
1520
1520
+
Scan() (*DirectoryScanResult, error)
1521
1521
+
1522
1522
+
// Mempool
1523
1523
+
GetMempoolStats() map[string]interface{}
1524
1524
+
GetMempoolOperations() ([]PLCOperation, error)
1525
1525
+
ValidateMempool() error
1526
1526
+
ClearMempool() error
1527
1527
+
1528
1528
+
// Info
1529
1529
+
GetInfo() map[string]interface{}
1530
1530
+
IsBundleIndexed(bundleNumber int) bool
1531
1531
+
```
1532
1532
+
1533
1533
+
### Index Methods
1534
1534
+
1535
1535
+
```go
1536
1536
+
// Creation
1537
1537
+
NewIndex() *Index
1538
1538
+
LoadIndex(path string) (*Index, error)
1539
1539
+
1540
1540
+
// Persistence
1541
1541
+
Save(path string) error
1542
1542
+
1543
1543
+
// Queries
1544
1544
+
GetBundle(bundleNumber int) (*BundleMetadata, error)
1545
1545
+
GetLastBundle() *BundleMetadata
1546
1546
+
GetBundles() []*BundleMetadata
1547
1547
+
GetBundleRange(start, end int) []*BundleMetadata
1548
1548
+
1549
1549
+
// Stats
1550
1550
+
Count() int
1551
1551
+
FindGaps() []int
1552
1552
+
GetStats() map[string]interface{}
1553
1553
+
```
1554
1554
+
1555
1555
+
### Configuration Types
1556
1556
+
1557
1557
+
```go
1558
1558
+
type Config struct {
1559
1559
+
BundleDir string
1560
1560
+
VerifyOnLoad bool
1561
1561
+
AutoRebuild bool
1562
1562
+
RebuildWorkers int
1563
1563
+
RebuildProgress func(current, total int)
1564
1564
+
Logger Logger
1565
1565
+
}
1566
1566
+
1567
1567
+
type Logger interface {
1568
1568
+
Printf(format string, v ...interface{})
1569
1569
+
Println(v ...interface{})
1570
1570
+
}
1571
1571
+
```
1572
1572
+
1573
1573
+
---
1574
1574
+
1575
1575
+
## Troubleshooting
1576
1576
+
1577
1577
+
### Bundle Not Found Error
1578
1578
+
1579
1579
+
```go
1580
1580
+
bundle, err := mgr.Load(ctx, 999)
1581
1581
+
if err != nil {
1582
1582
+
if strings.Contains(err.Error(), "not in index") {
1583
1583
+
// Bundle doesn't exist
1584
1584
+
log.Printf("Bundle 999 hasn't been fetched yet")
1585
1585
+
}
1586
1586
+
}
1587
1587
+
```
1588
1588
+
1589
1589
+
### Insufficient Operations Error
1590
1590
+
1591
1591
+
```go
1592
1592
+
bundle, err := mgr.FetchNext(ctx)
1593
1593
+
if err != nil {
1594
1594
+
if strings.Contains(err.Error(), "insufficient operations") {
1595
1595
+
// Not enough operations for a complete bundle
1596
1596
+
// Check mempool
1597
1597
+
stats := mgr.GetMempoolStats()
1598
1598
+
count := stats["count"].(int)
1599
1599
+
log.Printf("Only %d operations available (need %d)", count, plcbundle.BUNDLE_SIZE)
1600
1600
+
}
1601
1601
+
}
1602
1602
+
```
1603
1603
+
1604
1604
+
### Memory Usage
1605
1605
+
1606
1606
+
If processing large numbers of bundles:
1607
1607
+
1608
1608
+
```go
1609
1609
+
// Force garbage collection between bundles
1610
1610
+
for _, meta := range index.GetBundles() {
1611
1611
+
bundle, _ := mgr.Load(ctx, meta.BundleNumber)
1612
1612
+
processBundle(bundle)
1613
1613
+
1614
1614
+
runtime.GC() // Help garbage collector
1615
1615
+
}
1616
1616
+
```
1617
1617
+
1618
1618
+
---
1619
1619
+
1620
1620
+
## Examples Repository
1621
1621
+
1622
1622
+
Find complete, runnable examples at:
1623
1623
+
- https://github.com/plcbundle/examples
1624
1624
+
1625
1625
+
Including:
1626
1626
+
- Complete sync service
1627
1627
+
- API server
1628
1628
+
- Analysis tools
1629
1629
+
- Monitoring services
1630
1630
+
-7
plc_bundles.json
···
1
1
-
{
2
2
-
"version": "1.0",
3
3
-
"last_bundle": 0,
4
4
-
"updated_at": "2025-10-28T20:48:55.692096Z",
5
5
-
"total_size_bytes": 0,
6
6
-
"bundles": []
7
7
-
}