EV-Nats

 view release on metacpan or  search on metacpan

lib/EV/Nats/ObjectStore.pm  view on Meta::CPAN

    my $prefix = '$O.' . $self->{bucket} . '.M.';
    my @try = $prefix . _encode_name($name);
    my $legacy = $prefix . _encode_name_legacy($name);
    push @try, $legacy if $legacy ne $try[0];

    my $attempt;
    $attempt = sub {
        my $meta_subj = shift @try;
        if (!defined $meta_subj) {
            # Break the $attempt self-cycle before firing $cb.
            undef $attempt;
            return $cb->(undef, undef, 0, undef);
        }
        $self->{js}->_json_api(
            'STREAM.MSG.GET.' . $self->{stream},
            { last_by_subj => $meta_subj },
            sub {
                my ($resp, $err) = @_;
                if ($err) {
                    # Clean miss on this subject -> try the fallback form.
                    return $attempt->() if $err =~ /no message found|10037/;
                    undef $attempt;
                    return $cb->(undef, undef, 0, $err);
                }
                my $msg = $resp->{message};
                return $attempt->() if !$msg;
                my $tombstone = EV::Nats::JetStream::msg_is_tombstone($msg);
                require MIME::Base64;
                my $meta_json = MIME::Base64::decode_base64($msg->{data} || '');
                my ($meta, $derr);
                # A tombstone's payload is empty; only parse real data.
                if (length $meta_json) {
                    ($meta, $derr) = EV::Nats::JetStream::decode_json_or_error($meta_json);
                    if ($derr) {
                        undef $attempt;
                        return $cb->(undef, $meta_subj, 0, "invalid metadata: $derr");
                    }
                }
                my $gone = ($tombstone || ($meta && $meta->{deleted})) ? 1 : 0;
                undef $attempt;
                $cb->($meta, $meta_subj, $gone, undef);
            }
        );
    };
    $attempt->();
}

sub _nuid {
    my @chars = ('A'..'Z', 'a'..'z', '0'..'9');
    join '', map { $chars[rand @chars] } 1..22;
}

# padded base64url of a byte/most-strings value, matching Go URLEncoding
sub _b64url {
    my $bytes = shift;
    utf8::encode($bytes) if utf8::is_utf8($bytes);   # names may be wide
    require MIME::Base64;
    (my $e = MIME::Base64::encode_base64($bytes, '')) =~ tr{+/}{-_};
    return $e;                                        # keeps '=' padding
}
# inverse: padded-or-unpadded base64url -> UTF-8-decoded string
sub _b64url_dec {
    my $s = shift;
    $s =~ tr{-_}{+/};
    require MIME::Base64;
    my $b = MIME::Base64::decode_base64($s);          # tolerant of padding
    utf8::decode($b);
    return $b;
}

# ADR-20 name encoding: padded base64url (nats.go). The 0.03-0.05 %XX
# percent form is kept as *_legacy for the dual-read fallback.
sub _encode_name { _b64url($_[0]) }
sub _decode_name { _b64url_dec($_[0]) }
sub _encode_name_legacy { my $n = $_[0]; $n =~ s/([^A-Za-z0-9._-])/sprintf("%%%02X", ord($1))/ge; $n }
sub _decode_name_legacy { my $n = $_[0]; $n =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; $n }

# Best-effort name decode for list(): the base64url form wins when
# re-encoding round-trips, otherwise fall back to the legacy %XX form.
sub _decode_name_best {
    my $tok = shift;
    my $dec = _b64url_dec($tok);
    return $dec if _encode_name($dec) eq $tok;
    return _decode_name_legacy($tok);
}

1;

=head1 NAME

EV::Nats::ObjectStore - Chunked object store on top of NATS JetStream

=head1 SYNOPSIS

    use EV;
    use EV::Nats;
    use EV::Nats::JetStream;
    use EV::Nats::ObjectStore;

    my $nats = EV::Nats->new(host => '127.0.0.1');
    my $js   = EV::Nats::JetStream->new(nats => $nats);
    my $os   = EV::Nats::ObjectStore->new(js => $js, bucket => 'files');

    $os->create_bucket({}, sub {
        $os->put('report.pdf', $pdf_data, sub {
            my ($info, $err) = @_;
            print "stored: $info->{size} bytes in $info->{chunks} chunks\n";
            $os->get('report.pdf', sub {
                my ($data, $err, $meta) = @_;
                print "got $meta->{size} bytes back\n";
            });
        });
    });

    EV::run;

=head1 DESCRIPTION

An object-store bucket is a JetStream stream named C<OBJ_E<lt>bucketE<gt>>
with two subject groups:

lib/EV/Nats/ObjectStore.pm  view on Meta::CPAN

Callback: C<($info, $err)>.

=head2 delete_bucket

    delete_bucket($cb)

Tear down the underlying stream. Callback: C<($info, $err)>.

=head2 put

    put($name, $data, $cb)

Store C<$data> under C<$name>, automatically chunked. Each chunk is
published with JetStream ack; the metadata entry is written last
(with a C<Nats-Rollup: sub> header, so the meta subject holds exactly
one message) so a partial upload doesn't surface a half-stored object.
Overwriting an existing name purges the previous object's chunks after
the new metadata is acknowledged. Callback: C<($info, $err)> where
C<$info> is C<{ name, size, chunks, seq }>.

=head2 get

    get($name, $cb)

Retrieve a previously-stored object. Callback: C<($data, $err, $meta)>.
C<$data> is C<undef> if the object does not exist or has been deleted
(both the old C<KV-Operation> tombstone and the nats.go
C<"deleted":true> marker are recognised). On digest mismatch, C<$data>
is C<undef> and C<$err> is C<"digest mismatch">. Digests written by
0.03/0.04 (hex), 0.05 (unpadded base64url) and 0.06+/nats.go (padded
base64url) all verify.

=head2 delete

    delete($name, [$cb])

Publishes a C<"deleted":true> metadata marker with a C<Nats-Rollup:
sub> header (the nats.go form; the PubAck doubles as the flush fence),
then best-effort purges the object's chunks under
C<$O.E<lt>bucketE<gt>.C.E<lt>nuidE<gt>>. Marking is done first, so a
purge hiccup cannot leave a "deleted" object still readable. Idempotent:
deleting a missing or already-deleted object is a no-op success and
writes no marker. Callback: C<($ok, $err)>.

=head2 info

    info($name, $cb)

Fetch only the metadata entry for an object, without downloading
chunks. Callback: C<(\%meta, $err)>; C<\%meta> is C<undef> if the
object does not exist or was deleted (the deletion marker is
recognised). This is the recommended way to filter live objects out
of a L</list> result.

=head2 list

    list($cb)

List object names in the bucket. Callback: C<(\@names, $err)>.
Names written by 0.03-0.05 (legacy %XX encoding) and by 0.06+/nats.go
(base64url) are both decoded. Deleted entries still appear in the
listing -- filtering them would cost a per-name metadata round-trip,
so call C<info> to filter.

=head2 status

    status($cb)

Returns a snapshot hashref:

    { bucket => $name, bytes => $n, sealed => 0|1 }

C<sealed> reflects the underlying stream's C<config.sealed> flag;
this client never seals on its own, so unless someone manually
sealed the stream out-of-band the value is always 0.

Callback: C<(\%status, $err)>.

=head1 SEE ALSO

L<EV::Nats>, L<EV::Nats::JetStream>, L<EV::Nats::KV>,
L<NATS Object Store|https://docs.nats.io/using-nats/developer/develop_jetstream/object>.

=cut



( run in 0.620 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )