···27```
2829> ⚠️ **Preview Version - Do Not Use in Production!**
30->
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.
3233plcbundle 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.
3435-This repository contains a reference library and a CLI tool written in Go language.
0003637-The technical specification for the plcbundle V1 format, index, and creation process can be found in the [specification document](./SPECIFICATION.md).
3839-* [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-* [Reference implementations in TypeScript, Python, Ruby](https://tangled.org/@atscan.net/plcbundle-js/blob/main/plcbundle.ts)
4142-## Features
0004344-- 📦 **Bundle Management**: Automatically organize PLC operations into compressed bundles (10,000 operations each)
45-- 🔄 **Transparent Sync**: Fetch and cache PLC operations with automatic deduplication
46-- 🗜️ **Efficient Storage**: Zstandard compression with configurable levels
47-- ✅ **Integrity**: SHA-256 hash verification and blockchain-like chain validation
48-- 🔍 **Indexing**: Fast bundle lookup and gap detection
49-- 📊 **Export**: Query operations by time range
5051-## Installation
52-53-```bash
54-go get tangled.org/atscan.net/plcbundle
55-```
56-57-For the CLI tool:
58-59-```bash
60-go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
61-```
62-63-## Quick Start (Library)
6465```go
66-package main
6768-import (
69- "context"
70- "log"
71- "time"
72-73- plcbundle "tangled.org/atscan.net/plcbundle"
74-)
75-76-func main() {
77- // Create a bundle manager
78- mgr, err := plcbundle.New("./plc_data", "https://plc.directory")
79- if err != nil {
80- log.Fatal(err)
81- }
82- defer mgr.Close()
83-84- // Fetch latest bundles
85- ctx := context.Background()
86- bundle, err := mgr.FetchNext(ctx)
87- if err != nil {
88- log.Fatal(err)
89- }
90-91- log.Printf("Fetched bundle %d with %d operations",
92- bundle.BundleNumber, len(bundle.Operations))
93-}
94-```
95-96-## Library Usage
97-98-### 1. Basic Setup
99-100-```go
101-import (
102- "context"
103- "plcbundle tangled.org/atscan.net/plcbundle"
104-)
105-106-// Create manager with defaults
107-mgr, err := plcbundle.New("./bundles", "https://plc.directory")
108-if err != nil {
109- log.Fatal(err)
110-}
111defer mgr.Close()
112-```
113-114-### 2. Custom Configuration
115116-```go
117-import (
118- "tangled.org/atscan.net/plcbundle/bundle"
119- "tangled.org/atscan.net/plcbundle/plc"
120-)
121-122-// Custom config
123-config := &bundle.Config{
124- BundleDir: "./my_bundles",
125- CompressionLevel: bundle.CompressionBest,
126- VerifyOnLoad: true,
127- Logger: myCustomLogger,
128-}
129-130-// Custom PLC client with rate limiting
131-plcClient := plc.NewClient("https://plc.directory",
132- plc.WithRateLimit(60, time.Minute), // 60 req/min
133- plc.WithTimeout(30*time.Second),
134-)
135-136-mgr, err := bundle.NewManager(config, plcClient)
137-```
138-139-### 3. Transparent Synchronization (Main Use Case)
140-141-This is the primary pattern for keeping your local PLC mirror up-to-date:
142-143-```go
144-package main
145-146-import (
147- "context"
148- "log"
149- "time"
150-151- plcbundle "tangled.org/atscan.net/plcbundle"
152-)
153-154-type PLCSync struct {
155- mgr *plcbundle.BundleManager
156- ctx context.Context
157- cancel context.CancelFunc
158-}
159-160-func NewPLCSync(bundleDir string) (*PLCSync, error) {
161- mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
162- if err != nil {
163- return nil, err
164- }
165-166- ctx, cancel := context.WithCancel(context.Background())
167-168- sync := &PLCSync{
169- mgr: mgr,
170- ctx: ctx,
171- cancel: cancel,
172- }
173-174- return sync, nil
175-}
176-177-func (s *PLCSync) Start(interval time.Duration) {
178- ticker := time.NewTicker(interval)
179- defer ticker.Stop()
180-181- log.Println("Starting PLC synchronization...")
182-183- for {
184- select {
185- case <-ticker.C:
186- if err := s.Update(); err != nil {
187- log.Printf("Update error: %v", err)
188- }
189- case <-s.ctx.Done():
190- return
191- }
192- }
193-}
194-195-func (s *PLCSync) Update() error {
196- log.Println("Checking for new bundles...")
197-198- for {
199- bundle, err := s.mgr.FetchNext(s.ctx)
200- if err != nil {
201- // Check if we're caught up
202- if isEndOfData(err) {
203- log.Println("✓ Up to date!")
204- return nil
205- }
206- return err
207- }
208-209- log.Printf("✓ Fetched bundle %06d (%d ops, %d DIDs)",
210- bundle.BundleNumber,
211- len(bundle.Operations),
212- bundle.DIDCount)
213- }
214-}
215-216-func (s *PLCSync) Stop() {
217- s.cancel()
218- s.mgr.Close()
219-}
220-221-func isEndOfData(err error) bool {
222- return err != nil &&
223- (strings.Contains(err.Error(), "insufficient operations") ||
224- strings.Contains(err.Error(), "caught up"))
225-}
226-227-// Usage
228-func main() {
229- sync, err := NewPLCSync("./plc_bundles")
230- if err != nil {
231- log.Fatal(err)
232- }
233- defer sync.Stop()
234-235- // Update every 5 minutes
236- sync.Start(5 * time.Minute)
237-}
238-```
239-240-### 4. Getting Bundles
241-242-```go
243-ctx := context.Background()
244-245-// Get all bundles
246-index := mgr.GetIndex()
247-bundles := index.GetBundles()
248-249-for _, meta := range bundles {
250- log.Printf("Bundle %06d: %d ops, %s to %s",
251- meta.BundleNumber,
252- meta.OperationCount,
253- meta.StartTime.Format(time.RFC3339),
254- meta.EndTime.Format(time.RFC3339))
255-}
256-257-// Load specific bundle
258-bundle, err := mgr.Load(ctx, 1)
259-if err != nil {
260- log.Fatal(err)
261-}
262-263-log.Printf("Loaded %d operations", len(bundle.Operations))
264-```
265-266-### 5. Getting Operations from Bundles
267-268-```go
269-// Load a bundle and iterate operations
270-bundle, err := mgr.Load(ctx, 1)
271-if err != nil {
272- log.Fatal(err)
273-}
274-275-for _, op := range bundle.Operations {
276- log.Printf("DID: %s, CID: %s, Time: %s",
277- op.DID,
278- op.CID,
279- op.CreatedAt.Format(time.RFC3339))
280-281- // Access operation data
282- if opType, ok := op.Operation["type"].(string); ok {
283- log.Printf(" Type: %s", opType)
284- }
285-}
286-```
287-288-### 6. Export Operations by Time Range
289-290-```go
291-// Export operations after a specific time
292-afterTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
293-operations, err := mgr.Export(ctx, afterTime, 5000)
294-if err != nil {
295- log.Fatal(err)
296-}
297-298-log.Printf("Exported %d operations", len(operations))
299-300-// Process operations
301-for _, op := range operations {
302- // Your processing logic
303- processOperation(op)
304-}
305-```
306-307-### 7. Periodic Update Pattern
308-309-```go
310-// Simple periodic updater
311-func runPeriodicUpdate(mgr *plcbundle.BundleManager, interval time.Duration) {
312- ticker := time.NewTicker(interval)
313- defer ticker.Stop()
314-315- for range ticker.C {
316- ctx := context.Background()
317-318- // Try to fetch next bundle
319- bundle, err := mgr.FetchNext(ctx)
320- if err != nil {
321- if strings.Contains(err.Error(), "insufficient operations") {
322- log.Println("Caught up!")
323- continue
324- }
325- log.Printf("Error: %v", err)
326- continue
327- }
328-329- log.Printf("New bundle %d: %d operations",
330- bundle.BundleNumber,
331- len(bundle.Operations))
332-333- // Process new operations
334- for _, op := range bundle.Operations {
335- handleOperation(op)
336- }
337- }
338-}
339-340-// Usage
341-go runPeriodicUpdate(mgr, 10*time.Minute)
342-```
343-344-### 8. Verify Integrity
345-346-```go
347-// Verify specific bundle
348-result, err := mgr.Verify(ctx, 1)
349-if err != nil {
350- log.Fatal(err)
351-}
352-353-if result.Valid {
354- log.Println("✓ Bundle is valid")
355-} else {
356- log.Printf("✗ Invalid: %v", result.Error)
357-}
358-359-// Verify entire chain
360-chainResult, err := mgr.VerifyChain(ctx)
361-if err != nil {
362- log.Fatal(err)
363-}
364-365-if chainResult.Valid {
366- log.Printf("✓ Chain verified: %d bundles", chainResult.ChainLength)
367-} else {
368- log.Printf("✗ Chain broken at bundle %d: %s",
369- chainResult.BrokenAt,
370- chainResult.Error)
371-}
372```
373374-### 9. Scan Directory (Re-index)
375-376-```go
377-// Scan directory and rebuild index from existing bundles
378-result, err := mgr.Scan()
379-if err != nil {
380- log.Fatal(err)
381-}
382-383-log.Printf("Scanned %d bundles", result.BundleCount)
384-if len(result.MissingGaps) > 0 {
385- log.Printf("Warning: Missing bundles: %v", result.MissingGaps)
386-}
387-```
388-389-### 10. Complete Example: Background Sync Service
390-391-```go
392-package main
393-394-import (
395- "context"
396- "log"
397- "os"
398- "os/signal"
399- "syscall"
400- "time"
401-402- plcbundle "tangled.org/atscan.net/plcbundle"
403-)
404-405-type PLCService struct {
406- mgr *plcbundle.BundleManager
407- updateCh chan struct{}
408- stopCh chan struct{}
409-}
410-411-func NewPLCService(bundleDir string) (*PLCService, error) {
412- mgr, err := plcbundle.New(bundleDir, "https://plc.directory")
413- if err != nil {
414- return nil, err
415- }
416-417- return &PLCService{
418- mgr: mgr,
419- updateCh: make(chan struct{}, 1),
420- stopCh: make(chan struct{}),
421- }, nil
422-}
423-424-func (s *PLCService) Start() {
425- log.Println("Starting PLC service...")
426-427- // Initial scan
428- if _, err := s.mgr.Scan(); err != nil {
429- log.Printf("Scan warning: %v", err)
430- }
431-432- // Start update loop
433- go s.updateLoop()
434-435- // Periodic trigger
436- go s.periodicTrigger(5 * time.Minute)
437-}
438439-func (s *PLCService) updateLoop() {
440- for {
441- select {
442- case <-s.updateCh:
443- s.fetchNewBundles()
444- case <-s.stopCh:
445- return
446- }
447- }
448-}
449450-func (s *PLCService) periodicTrigger(interval time.Duration) {
451- ticker := time.NewTicker(interval)
452- defer ticker.Stop()
453-454- for {
455- select {
456- case <-ticker.C:
457- s.TriggerUpdate()
458- case <-s.stopCh:
459- return
460- }
461- }
462-}
463464-func (s *PLCService) TriggerUpdate() {
465- select {
466- case s.updateCh <- struct{}{}:
467- default:
468- // Update already in progress
469- }
470-}
471-472-func (s *PLCService) fetchNewBundles() {
473- ctx := context.Background()
474- fetched := 0
475-476- for {
477- bundle, err := s.mgr.FetchNext(ctx)
478- if err != nil {
479- if isEndOfData(err) {
480- if fetched > 0 {
481- log.Printf("✓ Fetched %d new bundles", fetched)
482- }
483- return
484- }
485- log.Printf("Fetch error: %v", err)
486- return
487- }
488-489- fetched++
490- log.Printf("Bundle %06d: %d operations",
491- bundle.BundleNumber,
492- len(bundle.Operations))
493- }
494-}
495-496-func (s *PLCService) GetBundles() []*plcbundle.BundleMetadata {
497- return s.mgr.GetIndex().GetBundles()
498-}
499-500-func (s *PLCService) GetOperations(bundleNum int) ([]plcbundle.PLCOperation, error) {
501- ctx := context.Background()
502- bundle, err := s.mgr.Load(ctx, bundleNum)
503- if err != nil {
504- return nil, err
505- }
506- return bundle.Operations, nil
507-}
508-509-func (s *PLCService) Stop() {
510- close(s.stopCh)
511- s.mgr.Close()
512-}
513-514-func main() {
515- service, err := NewPLCService("./plc_data")
516- if err != nil {
517- log.Fatal(err)
518- }
519-520- service.Start()
521-522- // Wait for interrupt
523- sigCh := make(chan os.Signal, 1)
524- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
525- <-sigCh
526-527- log.Println("Shutting down...")
528- service.Stop()
529-}
530-```
531-532-## CLI Tool Usage
533-534-### Fetch bundles
535-536-```bash
537-# Fetch next bundle
538plcbundle fetch
539540-# Fetch specific number of bundles
541-plcbundle fetch -count 10
542-543-# Fetch all available bundles
544-plcbundle fetch -count 0
545-```
546-547-### Scan directory
548549-```bash
550-# Scan and rebuild index
551-plcbundle scan
552-```
553-554-### Verify integrity
555-556-```bash
557-# Verify specific bundle
558-plcbundle verify -bundle 1
559-560-# Verify entire chain
561plcbundle verify
562-563-# Verbose output
564-plcbundle verify -v
565```
566567-### Show information
568569-```bash
570-# General info
571-plcbundle info
572573-# Specific bundle info
574-plcbundle info -bundle 1
575-```
0000576577-### Export operations
578579```bash
580-# Export operations to stdout (JSONL)
581-plcbundle export -count 1000 > operations.jsonl
582583-# Export after specific time
584-plcbundle export -after "2024-01-01T00:00:00Z" -count 5000
585```
586587-### Backfill
588589-```bash
590-# Fetch all bundles and stream to stdout
591-plcbundle backfill > all_operations.jsonl
592-593-# Start from specific bundle
594-plcbundle backfill -start 100 -end 200
595-```
596-597-## API Reference
598-599-### Types
600-601-```go
602-type BundleManager struct { ... }
603-type Bundle struct {
604- BundleNumber int
605- StartTime time.Time
606- EndTime time.Time
607- Operations []PLCOperation
608- DIDCount int
609- Hash string
610- // ...
611-}
612-613-type PLCOperation struct {
614- DID string
615- Operation map[string]interface{}
616- CID string
617- CreatedAt time.Time
618- RawJSON []byte
619-}
620-```
621-622-### Methods
623-624-```go
625-// Create
626-New(bundleDir, plcURL string) (*BundleManager, error)
627628-// Sync
629-FetchNext(ctx) (*Bundle, error)
630-Export(ctx, afterTime, count) ([]PLCOperation, error)
631632-// Query
633-Load(ctx, bundleNumber) (*Bundle, error)
634-GetIndex() *Index
635-GetInfo() map[string]interface{}
0636637-// Verify
638-Verify(ctx, bundleNumber) (*VerificationResult, error)
639-VerifyChain(ctx) (*ChainVerificationResult, error)
640641-// Manage
642-Scan() (*DirectoryScanResult, error)
643-Close()
644-```
645646-## Configuration
647648-```go
649-type Config struct {
650- BundleDir string // Storage directory
651- CompressionLevel CompressionLevel // Compression level
652- VerifyOnLoad bool // Verify hashes when loading
653- Logger Logger // Custom logger
654-}
655-```
656657## License
658···660661## Contributing
662663-Contributions welcome! Please open an issue or PR.
···27```
2829> ⚠️ **Preview Version - Do Not Use in Production!**
003031plcbundle 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.
3233+* 📄 [Technical Specification](./docs/specification.md)
34+* 📚 [Library Documentation](./docs/library.md)
35+* 💻 [CLI Guide](./docs/cli.md)
36+* 📰 [Announcement Article](https://leaflet.pub/feb982b4-64cb-4549-9d25-d7e68cecb11a)
3738+## What is `plcbundle`?
3940+plcbundle solves the problem of synchronizing and archiving PLC directory operations by:
04142+- **Bundling**: Groups 10,000 operations into compressed, immutable files
43+- **Chaining**: Each bundle is cryptographically linked to the previous one
44+- **Verifiable**: SHA-256 hashes ensure data integrity throughout the chain
45+- **Efficient**: Zstandard compression with ~5x compression ratios
4647+## Quick Start
000004849+### As a Library
0000000000005051```go
52+import plcbundle "tangled.org/atscan.net/plcbundle"
5354+mgr, _ := plcbundle.New("./plc_data", "https://plc.directory")
00000000000000000000000000000000000000000055defer mgr.Close()
0005657+bundle, _ := mgr.FetchNext(context.Background())
58+// Process bundle.Operations
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000059```
6061+[See full library documentation →](./docs/library.md)
0000000000000000000000000000000000000000000000000000000000000006263+### As a CLI Tool
0000000006465+```bash
66+# Install
67+go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
00000000006869+# Fetch bundles
000000000000000000000000000000000000000000000000000000000000000000000000070plcbundle fetch
7172+# Clone from remote
73+plcbundle clone https://plc.example.com
0000007475+# Verify integrity
0000000000076plcbundle verify
00077```
7879+[See full CLI reference →](./docs/cli.md)
8081+## Key Features
008283+- 📦 Automatic bundle management (10,000 operations each)
84+- 🔄 Transparent synchronization with PLC directory
85+- 🗜️ Efficient zstd compression
86+- ✅ Cryptographic verification (SHA-256 + chain validation)
87+- 🔍 Fast indexing and gap detection
88+- 🌐 HTTP server for hosting bundles
89+- 🔌 WebSocket streaming support
9091+## Installation
9293```bash
94+# Library
95+go get tangled.org/atscan.net/plcbundle
9697+# CLI tool
98+go install tangled.org/atscan.net/plcbundle/cmd/plcbundle@latest
99```
100101+## Use Cases
102103+- **Archiving**: Create verifiable backups of PLC operations
104+- **Mirroring**: Host your own PLC directory mirror
105+- **Research**: Analyze historical DID operations
106+- **Compliance**: Maintain tamper-evident audit trails
0000000000000000000000000000000000107108+## Security Model
00109110+Bundles are cryptographically chained but require external verification:
111+- ✅ Verify against original PLC directory
112+- ✅ Compare with multiple independent mirrors
113+- ✅ Check published root and head hashes
114+- ✅ Anyone can reproduce bundles from PLC directory
115116+## Reference Implementations
00117118+- [TypeScript, Python, Ruby](https://tangled.org/@atscan.net/plcbundle-js/blob/main/plcbundle.ts)
000119120+## Documentation
121122+- [Library Guide](./docs/library.md) - Comprehensive API documentation
123+- [CLI Guide](./docs/cli.md) - Command-line tool usage
124+- [Specification](./docs/specification.md) - Technical format specification
125+<!--- [Examples](./docs/examples/) - Common patterns and recipes-->
0000126127## License
128···130131## Contributing
132133+Contributions welcome! Please open an issue or PR.