1package Prophet::Replica::prophet;
2{
3  $Prophet::Replica::prophet::VERSION = '0.751';
4}
5use Any::Moose;
6extends 'Prophet::FilesystemReplica';
7
8use Params::Validate qw(:all);
9use LWP::UserAgent;
10use LWP::ConnCache;
11use File::Spec ();
12use File::Path;
13use Cwd ();
14use File::Find;
15use Prophet::Util;
16use POSIX qw();
17use Memoize;
18use Prophet::ContentAddressedStore;
19
20use JSON;
21use Digest::SHA qw(sha1_hex);
22
23has '+db_uuid' => (
24    lazy    => 1,
25    default => sub { shift->_read_file('database-uuid') },
26);
27
28has _uuid => ( is => 'rw', );
29
30has _replica_version => (
31    is      => 'rw',
32    isa     => 'Int',
33    lazy    => 1,
34    default => sub { shift->_read_file('replica-version') || 0 }
35);
36
37has fs_root_parent => (
38    is      => 'rw',
39    lazy    => 1,
40    default => sub {
41        my $self = shift;
42        if ( $self->url =~ m{^file://(.*)} ) {
43            my $path = $1;
44            return File::Spec->catdir(
45                ( File::Spec->splitpath($path) )[ 0, -2 ] );
46        }
47    },
48);
49
50has fs_root => (
51    is      => 'rw',
52    lazy    => 1,
53    default => sub {
54        my $self = shift;
55        return $self->url =~ m{^file://(.*)$} ? $1 : undef;
56    },
57);
58
59has record_cas => (
60    is      => 'rw',
61    isa     => 'Prophet::ContentAddressedStore',
62    lazy    => 1,
63    default => sub {
64        my $self = shift;
65        Prophet::ContentAddressedStore->new(
66            {
67                fs_root => $self->fs_root,
68                root    => $self->record_cas_dir
69            }
70        );
71    },
72);
73
74has changeset_cas => (
75    is      => 'rw',
76    isa     => 'Prophet::ContentAddressedStore',
77    lazy    => 1,
78    default => sub {
79        my $self = shift;
80        Prophet::ContentAddressedStore->new(
81            {
82                fs_root => $self->fs_root,
83                root    => $self->changeset_cas_dir
84            }
85        );
86    },
87);
88
89has current_edit => (
90    is  => 'rw',
91    isa => 'Maybe[Prophet::ChangeSet]',
92);
93
94has current_edit_records => (
95    is      => 'rw',
96    isa     => 'ArrayRef',
97    default => sub { [] },
98);
99
100has '+resolution_db_handle' => (
101    isa     => 'Prophet::Replica | Undef',
102    lazy    => 1,
103    default => sub {
104        my $self = shift;
105        return if $self->is_resdb;
106        return Prophet::Replica->get_handle(
107            {
108                url        => "prophet:" . $self->url . '/resolutions',
109                app_handle => $self->app_handle,
110                is_resdb   => 1,
111            }
112        );
113    },
114);
115
116has backend => (
117    lazy    => 1,
118    is      => 'rw',
119    default => sub {
120        my $self = shift;
121        my $be;
122        if ( $self->url =~ /^http/i ) {
123            $be = 'Prophet::Replica::FS::Backend::LWP';
124        } else {
125            $be = 'Prophet::Replica::FS::Backend::File';
126        }
127
128        Prophet::App->require($be);
129        return $be->new( url => $self->url, fs_root => $self->fs_root );
130      }
131
132);
133
134use constant scheme   => 'prophet';
135use constant cas_root => 'cas';
136use constant record_cas_dir =>
137  File::Spec->catdir( __PACKAGE__->cas_root => 'records' );
138use constant changeset_cas_dir =>
139  File::Spec->catdir( __PACKAGE__->cas_root => 'changesets' );
140use constant record_dir         => 'records';
141use constant userdata_dir       => 'userdata';
142use constant changeset_index    => 'changesets.idx';
143use constant local_metadata_dir => 'local_metadata';
144
145sub BUILD {
146    my $self = shift;
147    my $args = shift;
148    Carp::cluck() unless ( $args->{app_handle} );
149    for ( $self->{url} ) {
150        s/^prophet://;    # url-based constructor in ::replica should do better
151        s{/$}{};
152    }
153
154}
155
156
157sub replica_version {
158    die "replica_version is read-only; you want set_replica_version."
159      if @_ > 1;
160    shift->_replica_version;
161}
162
163
164sub set_replica_version {
165    my $self    = shift;
166    my $version = shift;
167
168    $self->_replica_version($version);
169
170    $self->_write_file(
171        path    => 'replica-version',
172        content => $version,
173    );
174
175    return $version;
176}
177
178sub can_initialize {
179    my $self = shift;
180    if ( $self->fs_root_parent && -w $self->fs_root_parent ) {
181        return 1;
182
183    }
184    return 0;
185}
186
187use constant can_read_records    => 1;
188use constant can_read_changesets => 1;
189sub can_write_changesets { return ( shift->fs_root ? 1 : 0 ) }
190sub can_write_records    { return ( shift->fs_root ? 1 : 0 ) }
191
192sub _on_initialize_create_paths {
193    my $self = shift;
194    return (
195        $self->record_dir,     $self->cas_root,
196        $self->record_cas_dir, $self->changeset_cas_dir,
197        $self->userdata_dir
198    );
199
200}
201
202sub initialize_backend {
203    my $self = shift;
204    my %args = validate(
205        @_,
206        {
207            db_uuid    => 0,
208            resdb_uuid => 0,
209        }
210    );
211
212    $self->set_db_uuid( $args{'db_uuid'}
213          || $self->uuid_generator->create_str );
214    $self->set_latest_sequence_no("0");
215    $self->set_replica_uuid( $self->uuid_generator->create_str );
216
217    $self->set_replica_version(1);
218
219    $self->resolution_db_handle->initialize( db_uuid => $args{resdb_uuid} )
220      if !$self->is_resdb;
221}
222
223sub latest_sequence_no {
224    my $self = shift;
225    $self->_read_file('latest-sequence-no');
226}
227
228sub set_latest_sequence_no {
229    my $self = shift;
230    my $id   = shift;
231    $self->_write_file(
232        path    => 'latest-sequence-no',
233        content => scalar($id)
234    );
235}
236
237sub _increment_sequence_no {
238    my $self = shift;
239    my $seq  = $self->latest_sequence_no + 1;
240    $self->set_latest_sequence_no($seq);
241    return $seq;
242}
243
244
245sub uuid {
246    my $self = shift;
247    $self->_uuid( $self->_read_file('replica-uuid') ) unless $self->_uuid;
248
249    #    die $@ if $@;
250    return $self->_uuid;
251}
252
253sub set_replica_uuid {
254    my $self = shift;
255    my $uuid = shift;
256    $self->_write_file(
257        path    => 'replica-uuid',
258        content => $uuid
259    );
260
261}
262
263sub set_db_uuid {
264    my $self = shift;
265    my $uuid = shift;
266    $self->_write_file(
267        path    => 'database-uuid',
268        content => $uuid
269    );
270    $self->SUPER::set_db_uuid($uuid);
271}
272
273# Working with records {
274
275sub _write_record {
276    my $self   = shift;
277    my %args   = validate( @_, { record => { isa => 'Prophet::Record' }, } );
278    my $record = $args{'record'};
279
280    $self->_write_serialized_record(
281        type  => $record->type,
282        uuid  => $record->uuid,
283        props => $record->get_props,
284    );
285}
286
287sub _write_serialized_record {
288    my $self = shift;
289    my %args = validate( @_, { type => 1, uuid => 1, props => 1 } );
290
291    for ( keys %{ $args{'props'} } ) {
292        delete $args{'props'}->{$_}
293          if ( !defined $args{'props'}->{$_} || $args{'props'}->{$_} eq '' );
294    }
295    my $cas_key = $self->record_cas->write( $args{props} );
296
297    my $record = {
298        uuid    => $args{uuid},
299        type    => $args{type},
300        cas_key => $cas_key
301    };
302
303    $self->_prepare_record_index_update(
304        uuid    => $args{uuid},
305        type    => $args{type},
306        cas_key => $cas_key
307    );
308}
309
310sub _prepare_record_index_update {
311    my $self   = shift;
312    my %record = (@_);
313
314    # If we're inside an edit, we can record the changeset info into the index
315    if ( $self->current_edit ) {
316        push @{ $self->current_edit_records }, \%record;
317
318    } else {
319
320        # If we're not inside an edit, we're likely exporting the replica
321        # TODO: the replica exporter code should probably be retooled
322        $self->_write_record_index_entry(%record);
323    }
324
325}
326
327use constant RECORD_INDEX_SIZE => ( 4 + 20 );
328
329sub _write_record_index_entry {
330    my $self = shift;
331    my %args =
332      validate( @_,
333        { type => 1, uuid => 1, cas_key => 1, changeset_id => 0 } );
334    my $idx_filename = $self->_record_index_filename(
335        uuid => $args{uuid},
336        type => $args{type}
337    );
338
339    my $index_path = Prophet::Util->catfile( $self->fs_root, $idx_filename );
340    my ( undef, $parent, $filename ) = File::Spec->splitpath($index_path);
341    mkpath( [$parent] );
342
343    open( my $record_index, ">>", $index_path );
344
345    # XXX TODO: skip if the index already has this version of the record;
346    # XXX TODO FETCH THAT
347    my $record_last_changed_changeset = $args{'changeset_id'} || 0;
348    my $index_row =
349      pack( 'NH40', $record_last_changed_changeset, $args{cas_key} );
350    print $record_index $index_row || die $!;
351    close $record_index;
352}
353
354sub _read_file_range {
355    my $self = shift;
356    my %args = validate( @_, { path => 1, position => 1, length => 1 } );
357
358    return $self->backend->read_file_range(%args);
359
360}
361
362sub _last_record_index_entry {
363    my $self = shift;
364    my %args = ( type => undef, uuid => undef, @_ );
365
366    my $idx_filename;
367    my $record = $self->_read_file_range(
368        path => $self->_record_index_filename(
369            uuid => $args{uuid},
370            type => $args{type}
371        ),
372        position => ( 0 - RECORD_INDEX_SIZE ),
373        length   => RECORD_INDEX_SIZE
374    ) || return;
375
376    my ( $seq, $key ) = unpack( "NH40", $record );
377    return ( $seq, $key );
378}
379
380sub _read_record_index {
381    my $self = shift;
382    my %args = validate( @_, { type => 1, uuid => 1 } );
383
384    my $idx_filename = $self->_record_index_filename(
385        uuid => $args{uuid},
386        type => $args{type}
387    );
388
389    my $index = $self->backend->read_file($idx_filename);
390    return unless $index;
391
392    my $count = length($index) / RECORD_INDEX_SIZE;
393    my @entries;
394    for my $record ( 1 .. $count ) {
395        my ( $seq, $key ) = unpack(
396            'NH40',
397            substr(
398                $index, ( $record - 1 ) * RECORD_INDEX_SIZE,
399                RECORD_INDEX_SIZE
400            )
401        );
402        push @entries, [ $seq => $key ];
403    }
404    return @entries;
405}
406
407sub _delete_record_index {
408    my $self         = shift;
409    my %args         = validate( @_, { type => 1, uuid => 1 } );
410    my $idx_filename = $self->_record_index_filename(
411        uuid => $args{uuid},
412        type => $args{type}
413    );
414    unlink Prophet::Util->catfile( $self->fs_root => $idx_filename )
415      || die "Could not delete record $idx_filename: " . $!;
416}
417
418sub _read_serialized_record {
419    my $self = shift;
420    my %args = validate( @_, { type => 1, uuid => 1 } );
421
422    my $casfile = $self->_record_cas_filename(
423        type => $args{'type'},
424        uuid => $args{'uuid'}
425    );
426
427    return unless $casfile;
428    return from_json( $self->_read_file($casfile), { utf8 => 1 } );
429}
430
431# XXX TODO: memoize doesn't work on win:
432# t\resty-server will issue the following error:
433# Anonymous function called in forbidden list context; faulting
434memoize '_record_index_filename' unless $^O =~ /MSWin/;
435
436sub _record_index_filename {
437    my $self = shift;
438    my %args = validate( @_, { uuid => 1, type => 1 } );
439    return Prophet::Util->catfile(
440        $self->_record_type_dir( $args{'type'} ),
441        Prophet::Util::hashed_dir_name( $args{uuid} )
442    );
443}
444
445sub _record_cas_filename {
446    my $self = shift;
447    my %args = ( type => undef, uuid => undef, @_ );
448
449    my ( $seq, $key ) = $self->_last_record_index_entry(
450        type => $args{'type'},
451        uuid => $args{'uuid'}
452    );
453
454    return unless ( $key and ( $key ne '0' x 40 ) );
455    return $self->record_cas->filename($key);
456}
457
458sub _record_type_dir {
459    my $self = shift;
460    my $type = shift;
461    return File::Spec->catdir( $self->record_dir, $type );
462}
463
464# }
465
466
467sub changesets_for_record {
468    my $self = shift;
469    my %args = validate( @_, { uuid => 1, type => 1, limit => 0 } );
470
471    my @record_index = $self->_read_record_index(
472        type => $args{'type'},
473        uuid => $args{'uuid'}
474    );
475
476    my $changeset_index = $self->read_changeset_index();
477
478    my @changesets;
479    for my $item (@record_index) {
480        my $sequence = $item->[0];
481        push @changesets,
482          $self->_get_changeset_via_index(
483            sequence_no => $sequence,
484            index_file  => $changeset_index
485          );
486        last if ( defined $args{limit} && --$args{limit} );
487    }
488
489    return @changesets;
490
491}
492
493
494sub begin_edit {
495    my $self = shift;
496    my %args = validate(
497        @_,
498        {
499            source => 0,    # the changeset that we're replaying, if applicable
500        }
501    );
502
503    my $source = $args{source};
504
505    my $creator = $source ? $source->creator : $self->changeset_creator;
506    my $created = $source && $source->created;
507
508    require Prophet::ChangeSet;
509    my $changeset = Prophet::ChangeSet->new(
510        {
511            source_uuid => $self->uuid,
512            creator     => $creator,
513            $created ? ( created => $created ) : (),
514        }
515    );
516    $self->current_edit($changeset);
517    $self->current_edit_records( [] );
518}
519
520sub _set_original_source_metadata_for_current_edit {
521    my $self = shift;
522    my ($changeset) = validate_pos( @_, { isa => 'Prophet::ChangeSet' } );
523
524    $self->current_edit->original_source_uuid(
525        $changeset->original_source_uuid );
526    $self->current_edit->original_sequence_no(
527        $changeset->original_sequence_no );
528}
529
530sub commit_edit {
531    my $self     = shift;
532    my $sequence = $self->_increment_sequence_no;
533    $self->current_edit->original_sequence_no($sequence)
534      unless ( defined $self->current_edit->original_sequence_no );
535    $self->current_edit->original_source_uuid( $self->uuid )
536      unless ( $self->current_edit->original_source_uuid );
537    $self->current_edit->sequence_no($sequence);
538    for my $record ( @{ $self->current_edit_records } ) {
539        $self->_write_record_index_entry(
540            changeset_id => $sequence,
541            %$record
542        );
543    }
544    $self->_write_changeset_to_index( $self->current_edit );
545}
546
547sub _write_changeset_to_index {
548    my $self      = shift;
549    my $changeset = shift;
550    $self->_write_changeset( changeset => $changeset );
551    $self->current_edit(undef);
552}
553
554sub _after_record_changes {
555    my $self = shift;
556    my ($changeset) = validate_pos( @_, { isa => 'Prophet::ChangeSet' } );
557
558    $self->current_edit->is_nullification( $changeset->is_nullification );
559    $self->current_edit->is_resolution( $changeset->is_resolution );
560}
561
562sub create_record {
563    my $self = shift;
564    my %args = validate( @_, { uuid => 1, props => 1, type => 1 } );
565
566    my $inside_edit = $self->current_edit ? 1 : 0;
567    $self->begin_edit() unless ($inside_edit);
568
569    $self->_write_serialized_record(
570        type  => $args{'type'},
571        uuid  => $args{'uuid'},
572        props => $args{'props'}
573    );
574
575    my $change = Prophet::Change->new(
576        {
577            record_type => $args{'type'},
578            record_uuid => $args{'uuid'},
579            change_type => 'add_file'
580        }
581    );
582
583    for my $name ( keys %{ $args{props} } ) {
584        $change->add_prop_change(
585            name => $name,
586            old  => undef,
587            new  => $args{props}->{$name}
588        );
589    }
590
591    $self->current_edit->add_change( change => $change );
592
593    $self->commit_edit unless ($inside_edit);
594}
595
596sub delete_record {
597    my $self = shift;
598    my %args = validate( @_, { uuid => 1, type => 1 } );
599
600    my $inside_edit = $self->current_edit ? 1 : 0;
601    $self->begin_edit() unless ($inside_edit);
602
603    my $change = Prophet::Change->new(
604        {
605            record_type => $args{'type'},
606            record_uuid => $args{'uuid'},
607            change_type => 'delete'
608        }
609    );
610    $self->current_edit->add_change( change => $change );
611
612    $self->_prepare_record_index_update(
613        uuid    => $args{uuid},
614        type    => $args{type},
615        cas_key => '0' x 40
616    );
617
618    $self->commit_edit() unless ($inside_edit);
619    return 1;
620}
621
622sub set_record_props {
623    my $self = shift;
624    my %args = validate( @_, { uuid => 1, props => 1, type => 1 } );
625
626    my $inside_edit = $self->current_edit ? 1 : 0;
627    $self->begin_edit() unless ($inside_edit);
628
629    my $old_props = $self->get_record_props(
630        uuid => $args{'uuid'},
631        type => $args{'type'}
632    );
633    my %new_props = %$old_props;
634    for my $prop ( keys %{ $args{props} } ) {
635        if ( !defined $args{props}->{$prop} ) {
636            delete $new_props{$prop};
637        } else {
638            $new_props{$prop} = $args{props}->{$prop};
639        }
640    }
641    $self->_write_serialized_record(
642        type  => $args{'type'},
643        uuid  => $args{'uuid'},
644        props => \%new_props
645    );
646
647    my $change = Prophet::Change->new(
648        {
649            record_type => $args{'type'},
650            record_uuid => $args{'uuid'},
651            change_type => 'update_file'
652        }
653    );
654
655    for my $name ( keys %{ $args{props} } ) {
656        $change->add_prop_change(
657            name => $name,
658            old  => $old_props->{$name},
659            new  => $args{props}->{$name}
660        );
661    }
662    $self->current_edit->add_change( change => $change );
663
664    $self->commit_edit() unless ($inside_edit);
665    return 1;
666}
667
668sub get_record_props {
669    my $self = shift;
670    my %args = validate( @_, { uuid => 1, type => 1 } );
671    return $self->_read_serialized_record(
672        uuid => $args{'uuid'},
673        type => $args{'type'}
674    );
675}
676
677sub record_exists {
678    my $self = shift;
679    my %args = validate( @_, { uuid => 1, type => 1 } );
680    return unless $args{'uuid'};
681    return $self->_record_cas_filename(
682        type => $args{'type'},
683        uuid => $args{'uuid'}
684    ) ? 1 : 0;
685
686}
687
688sub list_records {
689    my $self = shift;
690    my %args = validate( @_ => { type => 1, record_class => 1 } );
691
692    return [] unless $self->type_exists( type => $args{type} );
693
694    #return just the filenames, which, File::Find::Rule doesn't seem capable of
695    my @record_uuids;
696    find sub { return unless -f $_; push @record_uuids, $_ },
697      File::Spec->catdir(
698        $self->fs_root => $self->_record_type_dir( $args{'type'} ) );
699
700    return [
701        map {
702            my $record = $args{record_class}->new(
703                {
704                    app_handle => $self->app_handle,
705                    handle     => $self,
706                    type       => $args{type}
707                }
708            );
709            $record->_instantiate_from_hash( uuid => $_ );
710            $record;
711          }
712          grep {
713            $self->_record_cas_filename( type => $args{'type'}, uuid => $_ )
714          } @record_uuids
715    ];
716
717}
718
719sub list_types {
720    my $self = shift;
721    opendir( my $dh,
722        File::Spec->catdir( $self->fs_root => $self->record_dir ) )
723      || die "can't open type directory $!";
724    my @types = grep { $_ !~ /^\./ } readdir($dh);
725    closedir $dh;
726    return \@types;
727}
728
729sub type_exists {
730    my $self = shift;
731    my %args = validate( @_, { type => 1 } );
732    return $self->_file_exists( $self->_record_type_dir( $args{'type'} ) );
733}
734
735__PACKAGE__->meta->make_immutable();
736no Any::Moose;
737
7381;
739
740__END__
741
742=pod
743
744=head1 NAME
745
746Prophet::Replica::prophet
747
748=head1 VERSION
749
750version 0.751
751
752=head1 METHODS
753
754=head2 replica_version
755
756Returns this replica's version.
757
758=head2 set_replica_version
759
760Sets the replica's version to the given integer.
761
762=head2 uuid
763
764Return the replica's UUID
765
766=head2 changesets_for_record { uuid => $uuid, type => $type, limit => $int }
767
768Returns an ordered set of changeset objects for all changesets containing
769changes to this object.
770
771Note that changesets may include changes to other records
772
773If "limit" is specified, only returns that many changesets (starting from
774record creation).
775
776=head2 begin_edit
777
778Creates a new L<Prophet::ChangeSet>, which new changes will be added to.
779
780=head1 Replica Format
781
782=head4 overview
783
784 $URL
785    /<db-uuid>/
786        /replica-uuid
787        /latest-sequence-no
788        /replica-version
789        /cas/records/<substr(sha1,0,1)>/substr(sha1,1,1)/<sha1>
790        /cas/changesets/<substr(sha1,0,1)>/substr(sha1,1,1)/<sha1>
791        /records (optional?)
792            /<record type> (for resolution is actually _prophet-resolution-<cas-key>)
793                /<record uuid> which is a file containing a list of 0 or more rows
794                    last-changed-sequence-no : cas key
795
796        /changesets.idx
797
798            index which has records:
799                each record is : local-replica-seq-no : original-uuid : original-seq-no : cas key
800            ...
801
802        /resolutions/
803            /replica-uuid
804            /latest-sequence-no
805            /cas/<substr(sha1,0,1)>/substr(sha1,1,1)/<sha1>
806            /content (optional?)
807                /_prophet-resolution-<cas-key>   (cas-key == a hash the conflicting change)
808                    /<record uuid>  (record uuid == the originating replica)
809                        last-changed-sequence-no : <cas key to the content of the resolution>
810
811            /changesets.idx
812                index which has records:
813                    each record is : local-replica-seq-no : original-uuid : original-seq-no : cas key
814                ...
815
816Inside the top level directory for the mirror, you'll find a directory named as
817B<a hex-encoded UUID>. This directory is the root of the published replica. The
818uuid uniquely identifes the database being replicated. All replicas of this
819database will share the same UUID.
820
821Inside the B<<db-uuid>> directory, are a set of files and directories that make
822up the actual content of the database replica:
823
824=over 2
825
826=item C<replica-uuid>
827
828Contains the replica's hex-encoded UUID.
829
830=item C<replica-version>
831
832Contains a single integer that defines the replica format.
833
834The current replica version is 1.
835
836=item C<latest-sequence-no>
837
838Contains a single integer, the replica's most recent sequence number.
839
840=item C<cas/records>
841
842=item C<cas/changesets>
843
844The C<cas> directory holds changesets and records, each keyed by a hex-encoded
845hash of the item's content. Inside the C<cas> directory, you'll find a
846two-level deep directory tree of single-character hex digits.  You'll find  the
847changeset with the sha1 digest  C<f4b7489b21f8d107ad8df78750a410c028abbf6c>
848inside C<cas/changesets/f/4/f4b7489b21f8d107ad8df78750a410c028abbf6c>.
849
850You'll find the record with the sha1 digest
851C<dd6fb674de879a1a4762d690141cdfee138daf65> inside
852C<cas/records/d/d/dd6fb674de879a1a4762d690141cdfee138daf65>.
853
854TODO: define the format for changesets and records
855
856=item C<records>
857
858Files inside the C<records> directory are index files which list off all
859published versions of a record and the key necessary to retrieve the record
860from the I<content-addressed store>.
861
862Inside the C<records> directory, you'll find directories named for each C<type>
863in your database. Inside each C<type> directory, you'll find a two-level
864directory tree of single hexadecimal digits. You'll find the record with the
865type <Foo> and the UUID C<29A3CA16-03C5-11DD-9AE0-E25CFCEE7EC4> stored in
866
867 records/Foo/2/9/29A3CA16-03C5-11DD-9AE0-E25CFCEE7EC4
868
869The format of record files is:
870
871    <unsigned-long-int: last-changed-sequence-no><40 chars of hex: cas key>
872
873The file is sorted in asecnding order by revision id.
874
875=item C<changesets.idx>
876
877The C<changesets.idx> file lists each changeset in this replica and provides an
878index into the B<content-addressed storage> to fetch the content of the
879changeset.
880
881The format of record files is:
882
883    <unsigned-long-int: sequence-no><16 bytes: changeset original source uuid><unsigned-long-int: changeset original source sequence no><16 bytes: cas key - sha1 sum of the changeset's content>
884
885The file is sorted in ascending order by revision id.
886
887=item C<resolutions>
888
889=over 2
890
891=item TODO DOC RESOLUTIONS
892
893=back
894
895=back
896
897=head1 AUTHORS
898
899=over 4
900
901=item *
902
903Jesse Vincent <jesse@bestpractical.com>
904
905=item *
906
907Chia-Liang Kao <clkao@bestpractical.com>
908
909=item *
910
911Christine Spang <christine@spang.cc>
912
913=back
914
915=head1 COPYRIGHT AND LICENSE
916
917This software is Copyright (c) 2009 by Best Practical Solutions.
918
919This is free software, licensed under:
920
921  The MIT (X11) License
922
923=head1 BUGS AND LIMITATIONS
924
925You can make new bug reports, and view existing ones, through the
926web interface at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Prophet>.
927
928=head1 CONTRIBUTORS
929
930=over 4
931
932=item *
933
934Alex Vandiver <alexmv@bestpractical.com>
935
936=item *
937
938Casey West <casey@geeknest.com>
939
940=item *
941
942Cyril Brulebois <kibi@debian.org>
943
944=item *
945
946Florian Ragwitz <rafl@debian.org>
947
948=item *
949
950Ioan Rogers <ioanr@cpan.org>
951
952=item *
953
954Jonas Smedegaard <dr@jones.dk>
955
956=item *
957
958Kevin Falcone <falcone@bestpractical.com>
959
960=item *
961
962Lance Wicks <lw@judocoach.com>
963
964=item *
965
966Nelson Elhage <nelhage@mit.edu>
967
968=item *
969
970Pedro Melo <melo@simplicidade.org>
971
972=item *
973
974Rob Hoelz <rob@hoelz.ro>
975
976=item *
977
978Ruslan Zakirov <ruz@bestpractical.com>
979
980=item *
981
982Shawn M Moore <sartak@bestpractical.com>
983
984=item *
985
986Simon Wistow <simon@thegestalt.org>
987
988=item *
989
990Stephane Alnet <stephane@shimaore.net>
991
992=item *
993
994Unknown user <nobody@localhost>
995
996=item *
997
998Yanick Champoux <yanick@babyl.dyndns.org>
999
1000=item *
1001
1002franck cuny <franck@lumberjaph.net>
1003
1004=item *
1005
1006robertkrimen <robertkrimen@gmail.com>
1007
1008=item *
1009
1010sunnavy <sunnavy@bestpractical.com>
1011
1012=back
1013
1014=cut
1015