├── .github └── workflows │ ├── bench.yml │ ├── pages.yml │ └── test.yml ├── .gitignore ├── .run ├── All Benchmarks.run.xml ├── All Unit Tests.run.xml ├── DynamoDBLocal.run.xml ├── Integration - All.run.xml ├── Integration - DynamoDB.run.xml ├── Integration - EventStoreDB.run.xml ├── Integration - Postgres Benchmark.run.xml ├── Integration - Postgres.run.xml ├── PostgreSQL.run.xml ├── Start RangeDB.run.xml ├── Vault Integration Test.run.xml └── Vault.run.xml ├── LICENSE ├── README.md ├── build ├── Dockerfile ├── README.md ├── docker-compose-pg.yml └── docker-compose.yml ├── cmd ├── gen-random-events │ └── main.go ├── grpc-event-subscriber │ └── main.go ├── rangedb │ └── main.go └── ws-event-subscriber │ └── main.go ├── event_identifier.go ├── events_test.go ├── examples ├── README.md └── chat │ ├── README.md │ ├── bind_events_gen.go │ ├── chat.go │ ├── chat_test.go │ ├── doc │ └── images │ │ └── chat-event-model.jpg │ ├── restricted_word_processor.go │ ├── room_aggregate.go │ ├── room_aggregate_gen.go │ ├── room_commands.go │ ├── room_commands_gen.go │ ├── room_events.go │ ├── room_events_gen.go │ ├── user_aggregate.go │ ├── user_aggregate_gen.go │ ├── user_commands.go │ ├── user_commands_gen.go │ ├── user_events.go │ ├── user_events_gen.go │ ├── warned_users_projection.go │ └── warned_users_projection_test.go ├── gen ├── aggregategenerator │ └── main.go ├── commandgenerator │ └── main.go ├── eventbinder │ └── main.go └── eventgenerator │ └── main.go ├── go.mod ├── go.sum ├── merge_record_iterators.go ├── merge_record_iterators_test.go ├── pkg ├── aggregategenerator │ ├── write_aggregate.go │ └── write_aggregate_test.go ├── broadcast │ ├── broadcaster.go │ └── broadcaster_test.go ├── clock │ ├── clock.go │ └── provider │ │ ├── seededclock │ │ ├── seeded_clock.go │ │ └── seeded_clock_test.go │ │ ├── sequentialclock │ │ ├── sequential_clock.go │ │ └── sequential_clock_test.go │ │ └── systemclock │ │ ├── system_clock.go │ │ └── system_clock_test.go ├── commandgenerator │ ├── write_command.go │ └── write_command_test.go ├── cqrs │ ├── cqrs.go │ └── cqrs_test.go ├── crypto │ ├── aes │ │ ├── aestest │ │ │ └── verify_aes_encryption.go │ │ ├── cbc_pkcs5_padding.go │ │ ├── cbc_pkcs5_padding_test.go │ │ ├── gcm.go │ │ └── gcm_test.go │ ├── crypto.go │ ├── cryptotest │ │ ├── customer_events.go │ │ ├── customer_events_gen.go │ │ ├── failing_event_encryptor.go │ │ ├── rot13_encryption.go │ │ ├── rot13_encryption_test.go │ │ └── verify_key_store.go │ ├── eventencryptor │ │ ├── delete_encryption_key_test.go │ │ ├── encrypt_event_test.go │ │ ├── event_encryptor.go │ │ ├── event_encryptor_test.go │ │ └── helper_test.go │ ├── provider │ │ ├── cachekeystore │ │ │ ├── cache_keystore.go │ │ │ └── cache_keystore_test.go │ │ ├── dynamodbkeystore │ │ │ ├── README.md │ │ │ ├── config.go │ │ │ ├── dynamodb_keystore.go │ │ │ └── dynamodb_keystore_test.go │ │ ├── inmemorykeystore │ │ │ ├── inmemory_keystore.go │ │ │ └── inmemory_keystore_test.go │ │ ├── postgreskeystore │ │ │ ├── postgres_keystore.go │ │ │ └── postgres_keystore_test.go │ │ └── vaultkeystore │ │ │ ├── hashicorp_vault_keystore.go │ │ │ └── hashicorp_vault_keystore_test.go │ └── xchacha20poly1305 │ │ ├── xchacha20poly1305.go │ │ └── xchacha20poly1305_test.go ├── eventparser │ ├── event_parser.go │ ├── event_parser_test.go │ ├── event_writer.go │ └── event_writer_test.go ├── grpc │ ├── README.md │ ├── build-proto.sh │ ├── rangedb.proto │ ├── rangedbpb │ │ ├── rangedb.pb.go │ │ ├── rangedb_grpc.pb.go │ │ ├── translate.go │ │ └── translate_test.go │ └── rangedbserver │ │ ├── get_all_events_test.go │ │ ├── get_events_by_aggregate_types_test.go │ │ ├── get_events_by_stream_test.go │ │ ├── helper_test.go │ │ ├── optimistic_delete_stream_failure_test.go │ │ ├── optimistic_delete_stream_test.go │ │ ├── optimistic_save_failure_test.go │ │ ├── optimistic_save_test.go │ │ ├── save_failure_response_test.go │ │ ├── save_test.go │ │ ├── server.go │ │ ├── server_test.go │ │ ├── subscribe_all_events_test.go │ │ └── subscribe_events_by_aggregate_type_test.go ├── jsontools │ └── json_tools.go ├── paging │ ├── paging.go │ └── paging_test.go ├── projection │ ├── aggregate_type_stats.go │ ├── aggregate_type_stats_test.go │ ├── disk_snapshot_store.go │ ├── disk_snapshot_store_test.go │ └── snapshot_store.go ├── rangedbapi │ ├── api.go │ ├── api_private_test.go │ ├── api_test.go │ ├── delete_stream_with_optimistic_concurrency_failure_test.go │ ├── delete_stream_with_optimistic_concurrency_test.go │ ├── get_all_events_test.go │ ├── get_events_by_aggregate_type_test.go │ ├── get_events_by_aggregate_types_test.go │ ├── get_events_by_stream_ndjson_test.go │ ├── get_events_by_stream_test.go │ ├── helper_test.go │ ├── save_events_test.go │ ├── save_events_with_optimistic_concurrency_failure_test.go │ └── save_events_with_optimistic_concurrency_test.go ├── rangedberror │ ├── errors.go │ └── errors_test.go ├── rangedbui │ ├── functions.go │ ├── gen │ │ └── pack-templates │ │ │ └── main.go │ ├── static │ │ ├── css │ │ │ ├── foundation-6.5.3.min.css │ │ │ ├── foundation-6.5.3.min.css.map │ │ │ ├── foundation-icons.css │ │ │ ├── foundation-icons.woff │ │ │ └── site.css │ │ ├── img │ │ │ ├── favicon.ico │ │ │ └── rangedb-logo-white-30x30.png │ │ └── js │ │ │ └── vue-3.2.20.global.prod.js │ ├── templates │ │ ├── aggregate-type-live.gohtml │ │ ├── aggregate-type.gohtml │ │ ├── aggregate-types-live.gohtml │ │ ├── aggregate-types.gohtml │ │ ├── layout │ │ │ ├── base.gohtml │ │ │ ├── pagination.gohtml │ │ │ └── records.gohtml │ │ └── stream.gohtml │ ├── ui.go │ └── ui_test.go ├── rangedbws │ ├── helper_test.go │ ├── stream_all_events_test.go │ ├── stream_events_by_aggregate_type_test.go │ ├── websocket_api.go │ ├── websocket_api_test.go │ └── websocket_private_test.go ├── recordsubscriber │ ├── config.go │ ├── config_test.go │ ├── record_subscriber.go │ └── record_subscriber_test.go ├── shortuuid │ ├── short_uuid.go │ └── short_uuid_test.go └── structparser │ ├── struct_names.go │ └── struct_names_test.go ├── provider ├── encryptedstore │ ├── decrypting_record_iterator.go │ ├── decrypting_record_iterator_test.go │ ├── decrypting_record_subscriber.go │ ├── decrypting_record_subscriber_test.go │ ├── encrypt_event_test.go │ ├── encrypted_store.go │ ├── encrypted_store_test.go │ └── helper_test.go ├── eventstore │ ├── README.md │ ├── eventstore.go │ └── eventstore_test.go ├── inmemorystore │ ├── inmemory_store.go │ └── inmemory_store_test.go ├── jsonrecordiostream │ ├── json_record_io_stream.go │ └── json_record_io_stream_test.go ├── jsonrecordserializer │ ├── json_record_serializer.go │ └── json_record_serializer_test.go ├── leveldbstore │ ├── leveldb_store.go │ ├── leveldb_store_private_test.go │ └── leveldb_store_test.go ├── msgpackrecordiostream │ ├── msgpack_record_io_stream.go │ └── msgpack_record_io_stream_test.go ├── msgpackrecordserializer │ ├── msgpack_record_serializer.go │ └── msgpack_record_serializer_test.go ├── ndjsonrecordiostream │ ├── ndjson_record_io_stream.go │ └── ndjson_record_io_stream_test.go ├── postgresstore │ ├── config.go │ ├── config_test.go │ ├── postgres_store.go │ ├── postgres_store_private_test.go │ └── postgres_store_test.go └── remotestore │ ├── remote_store.go │ └── remote_store_test.go ├── publish_record.go ├── publish_record_test.go ├── rangedbtest ├── bdd │ ├── bdd.go │ └── bdd_test.go ├── benchmark_record_io_stream.go ├── benchmark_record_serializer.go ├── benchmark_store.go ├── bind_events_gen.go ├── blocking_subscriber.go ├── cmd │ └── random-data │ │ └── main.go ├── context.go ├── events.go ├── failing_event_store.go ├── failing_serializer.go ├── failing_serializer_test.go ├── failing_subscribe_store.go ├── seeded_uuid_generator.go ├── total_events_subscriber.go ├── verify_record_io_stream.go ├── verify_record_serializer.go └── verify_store.go ├── record_io_stream.go ├── record_iterator.go ├── record_iterator_test.go ├── record_serializer.go ├── store.go └── store_test.go /.github/workflows/bench.yml: -------------------------------------------------------------------------------- 1 | name: Bench 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Bench 6 | runs-on: ubuntu-latest 7 | steps: 8 | 9 | - name: Set up Go 10 | uses: actions/setup-go@v1 11 | with: 12 | go-version: 1.16 13 | id: go 14 | 15 | - name: Check out code into the Go module directory 16 | uses: actions/checkout@v1 17 | 18 | - name: Install cob 19 | run: curl -sfL https://raw.githubusercontent.com/knqyf263/cob/master/install.sh | sudo sh -s -- -b /usr/local/bin 20 | 21 | - name: Run Benchmark 22 | run: cob --threshold 0.5 --base $(git describe --tags --abbrev=0) ./... 23 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["master"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Setup Pages 31 | uses: actions/configure-pages@v1 32 | - name: Build with Jekyll 33 | uses: actions/jekyll-build-pages@v1 34 | with: 35 | source: ./ 36 | destination: ./_site 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v1 39 | 40 | # Deployment job 41 | deploy: 42 | environment: 43 | name: github-pages 44 | url: ${{ steps.deployment.outputs.page_url }} 45 | runs-on: ubuntu-latest 46 | needs: build 47 | steps: 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v1 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | services: 11 | eventstoredb: 12 | image: eventstore/eventstore:21.10.0-bionic 13 | ports: 14 | - 2113:2113 15 | - 1113:1113 16 | env: 17 | EVENTSTORE_INSECURE: true 18 | postgres: 19 | image: postgres:14.1 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | vault: 30 | image: vault:1.9.1 31 | ports: 32 | - 8200:8200 33 | env: 34 | VAULT_DEV_ROOT_TOKEN_ID: testroot 35 | dynamodb-local: 36 | image: amazon/dynamodb-local:1.17.1 37 | ports: 38 | - 8900:8000 39 | steps: 40 | 41 | - name: Set up Go 42 | uses: actions/setup-go@v1 43 | with: 44 | go-version: 1.16 45 | id: go 46 | 47 | - name: Check out code into the Go module directory 48 | uses: actions/checkout@v2 49 | 50 | # - name: Login to Docker Hub 51 | # uses: docker/login-action@v1 52 | # with: 53 | # username: ${{ secrets.DOCKERHUB_USERNAME }} 54 | # password: ${{ secrets.DOCKERHUB_TOKEN }} 55 | 56 | - name: Test 57 | run: | 58 | go mod download 59 | go generate ./... 60 | go vet ./... 61 | go test -v -race -coverprofile c.out.tmp -failfast ./... 62 | env: 63 | PG_HOST: 127.0.0.1 64 | PG_USER: postgres 65 | PG_PASSWORD: postgres 66 | PG_DBNAME: postgres 67 | VAULT_ADDRESS: http://127.0.0.1:8200 68 | VAULT_TOKEN: testroot 69 | DYDB_ENDPOINT_URL: http://127.0.0.1:8900 70 | DYDB_AWS_REGION: us-east-1 71 | DYDB_TABLE_NAME: dummy 72 | AWS_ACCESS_KEY_ID: dummy 73 | AWS_SECRET_ACCESS_KEY: dummy 74 | ESDB_IP_ADDR: 127.0.0.1 75 | ESDB_USERNAME: admin 76 | ESDB_PASSWORD: changeit 77 | 78 | - name: Publish Code Coverage 79 | if: github.ref == 'refs/heads/master' 80 | env: 81 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 82 | run: | 83 | curl -s -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-reporter 84 | chmod +x ./cc-reporter 85 | ./cc-reporter before-build 86 | cat c.out.tmp | grep -v "_gen.go" | grep -v ".pb.go" | grep -v "/rangedbtest/" | grep -v "/cryptotest/" > c.out 87 | go tool cover -func c.out 88 | sed -i "s%github.com/inklabs/%%" c.out 89 | ./cc-reporter format-coverage c.out -t gocov -p $(basename $PWD) 90 | ./cc-reporter after-build -t gocov -p $(basename $PWD) 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .leveldb/ 3 | -------------------------------------------------------------------------------- /.run/All Benchmarks.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.run/All Unit Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.run/DynamoDBLocal.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.run/Integration - All.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | -------------------------------------------------------------------------------- /.run/Integration - DynamoDB.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | -------------------------------------------------------------------------------- /.run/Integration - EventStoreDB.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | -------------------------------------------------------------------------------- /.run/Integration - Postgres Benchmark.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.run/Integration - Postgres.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | -------------------------------------------------------------------------------- /.run/PostgreSQL.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.run/Start RangeDB.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.run/Vault Integration Test.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | -------------------------------------------------------------------------------- /.run/Vault.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Inklabs LLC., Jamie Isaacs 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RangeDB ![RangeDB Logo](https://github.com/inklabs/rangedb/blob/master/pkg/rangedbui/static/img/rangedb-logo-white-30x30.png) 2 | 3 | [![Build Status](https://travis-ci.org/inklabs/rangedb.svg?branch=master)](https://travis-ci.org/inklabs/rangedb) 4 | [![Docker Build Status](https://img.shields.io/docker/cloud/build/inklabs/rangedb)](https://hub.docker.com/r/inklabs/rangedb/builds) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/inklabs/rangedb)](https://goreportcard.com/report/github.com/inklabs/rangedb) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/c19eabe7c73ccc64738e/test_coverage)](https://codeclimate.com/github/inklabs/rangedb/test_coverage) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/c19eabe7c73ccc64738e/maintainability)](https://codeclimate.com/github/inklabs/rangedb/maintainability) 8 | [![GoDoc](https://godoc.org/github.com/inklabs/rangedb?status.svg)](https://godoc.org/github.com/inklabs/rangedb) 9 | [![Go Version](https://img.shields.io/github/go-mod/go-version/inklabs/rangedb.svg)](https://github.com/inklabs/rangedb/blob/master/go.mod) 10 | [![Release](https://img.shields.io/github/release/inklabs/rangedb.svg?include_prereleases&sort=semver)](https://github.com/inklabs/rangedb/releases/latest) 11 | [![Sourcegraph](https://sourcegraph.com/github.com/inklabs/rangedb/-/badge.svg)](https://sourcegraph.com/github.com/inklabs/rangedb?badge) 12 | [![License](https://img.shields.io/github/license/inklabs/rangedb.svg)](https://github.com/inklabs/rangedb/blob/master/LICENSE) 13 | 14 | RangeDB is an event store database written in Go. This package includes a stand-alone database 15 | and web server along with a library for embedding event sourced applications. 16 | 17 | Examples are provided [here](examples). 18 | 19 | ## Backend Engines 20 | 21 | RangeDB supports various backend database engines. 22 | 23 | - [PostgreSQL](https://www.postgresql.org/) 24 | - [LevelDB](https://github.com/google/leveldb) 25 | - [EventStoreDB](https://www.eventstore.com/eventstoredb) 26 | - [In Memory](https://github.com/inklabs/rangedb/tree/master/provider/inmemorystore) 27 | 28 | ### Coming Soon: 29 | 30 | - [Redis](https://redis.com/) 31 | - [Amazon DynamoDB](https://aws.amazon.com/dynamodb/) 32 | - [Axon Server](https://developer.axoniq.io/axon-server) 33 | 34 | ## Docker Quickstart 35 | 36 | ``` 37 | docker run -p 8080:8080 inklabs/rangedb 38 | ``` 39 | 40 | ## Community 41 | 42 | - [DDD-CQRS-ES slack group](https://github.com/ddd-cqrs-es/slack-community) channel: #rangedb 43 | - [Upcoming topics](https://github.com/inklabs/rangedb/wiki/Upcoming-Topics) for monthly pairing sessions 44 | 45 | 46 | ## Projects using RangeDB 47 | 48 | * [GOAuth2](https://github.com/inklabs/goauth2) - An OAuth2 Server in Go 49 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16.2-alpine AS build 2 | 3 | RUN apk add --update --no-cache ca-certificates gcc g++ libc-dev 4 | 5 | WORKDIR /code 6 | 7 | # Download Dependencies 8 | COPY go.mod . 9 | COPY go.sum . 10 | RUN go mod download 11 | 12 | # Test 13 | COPY . . 14 | RUN go generate ./... && go vet ./... && go test ./... 15 | 16 | # Build 17 | RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags -static" -o /go/bin/rangedb ./cmd/rangedb 18 | 19 | # Prepare final image 20 | FROM scratch AS release 21 | COPY --from=build /go/bin/rangedb /bin/rangedb 22 | ENTRYPOINT ["/bin/rangedb"] 23 | EXPOSE 8080 24 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # RangeDB Docker 2 | 3 | Docker containers are automatically built here: https://hub.docker.com/r/inklabs/rangedb 4 | 5 | ## Building Locally 6 | 7 | ### Build Image 8 | 9 | ``` 10 | docker build -f build/Dockerfile -t inklabs/rangedb:local . 11 | ``` 12 | 13 | ### Run Container 14 | 15 | ``` 16 | docker run -p 8080:8080 inklabs/rangedb:local 17 | ``` 18 | 19 | ## Using docker-compose to build & run locally in one step 20 | 21 | ### RangeDB InMemory 22 | * From root dir 23 | * docker-compose -f build/docker-compose.yml up --build 24 | 25 | ### RangeDB with Postgres 26 | * From root dir 27 | * docker-compose -f build/docker-compose-pg.yml up --build 28 | 29 | *remove **--build** to not rebuild image* 30 | -------------------------------------------------------------------------------- /build/docker-compose-pg.yml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | 4 | services: 5 | postgres: 6 | image: postgres 7 | container_name: pg_rangedb 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | # this will persist to host machine even after container is deleted 12 | volumes: 13 | - ~/postgres-data:/var/lib/postgresql/data 14 | ports: 15 | - 5432:5432 16 | healthcheck: 17 | test: pg_isready -U postgres -h 127.0.0.1 18 | interval: 10s 19 | timeout: 5s 20 | retries: 5 21 | 22 | rangedb: 23 | container_name: rangedb 24 | build: 25 | context: ../ 26 | dockerfile: ./build/Dockerfile 27 | environment: 28 | PG_HOST: postgres 29 | PG_USER: postgres 30 | PG_PASSWORD: postgres 31 | PG_DBNAME: postgres 32 | ports: 33 | - "8080:8080" 34 | depends_on: 35 | postgres: 36 | condition: service_healthy 37 | -------------------------------------------------------------------------------- /build/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.4" 2 | 3 | 4 | services: 5 | rangedb: 6 | build: 7 | context: ../ 8 | dockerfile: ./build/Dockerfile 9 | ports: 10 | - "8080:8080" 11 | -------------------------------------------------------------------------------- /cmd/ws-event-subscriber/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/url" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | 14 | "github.com/gorilla/websocket" 15 | ) 16 | 17 | func main() { 18 | fmt.Println("WebSocket Event Subscriber") 19 | 20 | aggregateTypesCSV := flag.String("aggregateTypes", "", "aggregateTypes separated by comma") 21 | host := flag.String("host", "127.0.0.1:8080", "RangeDB host address") 22 | flag.Parse() 23 | 24 | if *aggregateTypesCSV != "" { 25 | fmt.Printf("Subscribing to: %s\n", *aggregateTypesCSV) 26 | } else { 27 | fmt.Println("Subscribing to all events") 28 | } 29 | 30 | serverURL := url.URL{ 31 | Scheme: "ws", 32 | Host: *host, 33 | Path: "/ws/events", 34 | } 35 | 36 | if *aggregateTypesCSV != "" { 37 | serverURL.Path += "/" + *aggregateTypesCSV 38 | } 39 | 40 | ctx, done := context.WithCancel(context.Background()) 41 | defer done() 42 | socket, _, err := websocket.DefaultDialer.DialContext(ctx, serverURL.String(), nil) 43 | if err != nil { 44 | log.Fatalf("unable to dial (%s): %v", serverURL.String(), err) 45 | } 46 | defer closeOrLog(socket) 47 | 48 | stop := make(chan os.Signal) 49 | signal.Notify(stop, os.Interrupt, syscall.SIGTERM) 50 | 51 | go readEventsForever(socket, stop) 52 | 53 | <-stop 54 | 55 | fmt.Println("Shutting down") 56 | err = socket.WriteMessage(websocket.TextMessage, []byte("close")) 57 | if err != nil { 58 | log.Print("unable to write close message") 59 | } 60 | } 61 | 62 | func readEventsForever(socket *websocket.Conn, stop chan os.Signal) { 63 | for { 64 | select { 65 | case <-stop: 66 | return 67 | default: 68 | } 69 | 70 | _, message, err := socket.ReadMessage() 71 | if err != nil { 72 | log.Printf("error received: %v", err) 73 | stop <- syscall.SIGQUIT 74 | return 75 | } 76 | fmt.Println(string(message)) 77 | } 78 | } 79 | 80 | func closeOrLog(c io.Closer) { 81 | err := c.Close() 82 | if err != nil { 83 | log.Printf("failed closing: %v", err) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /event_identifier.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | type eventIdentifier struct { 8 | eventTypes map[string]reflect.Type 9 | } 10 | 11 | // NewEventIdentifier constructs an eventIdentifier. 12 | func NewEventIdentifier() *eventIdentifier { 13 | return &eventIdentifier{ 14 | eventTypes: map[string]reflect.Type{}, 15 | } 16 | } 17 | 18 | func (s *eventIdentifier) Bind(events ...Event) { 19 | for _, e := range events { 20 | s.eventTypes[e.EventType()] = getType(e) 21 | } 22 | } 23 | 24 | func (s *eventIdentifier) EventTypeLookup(eventTypeName string) (r reflect.Type, b bool) { 25 | eventType, ok := s.eventTypes[eventTypeName] 26 | return eventType, ok 27 | } 28 | 29 | func getType(object interface{}) reflect.Type { 30 | t := reflect.TypeOf(object) 31 | if t.Kind() == reflect.Ptr { 32 | t = t.Elem() 33 | } 34 | 35 | return t 36 | } 37 | -------------------------------------------------------------------------------- /events_test.go: -------------------------------------------------------------------------------- 1 | package rangedb_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/inklabs/rangedb" 10 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 11 | "github.com/inklabs/rangedb/rangedbtest" 12 | ) 13 | 14 | func TestBindEvents(t *testing.T) { 15 | // Given 16 | serializer := jsonrecordserializer.New() 17 | 18 | // When 19 | rangedbtest.BindEvents(serializer) 20 | 21 | // Then 22 | serializedData, err := serializer.Serialize(&rangedb.Record{ 23 | EventType: "ThingWasDone", 24 | Data: &rangedbtest.ThingWasDone{}, 25 | }) 26 | require.NoError(t, err) 27 | actualRecord, err := serializer.Deserialize(serializedData) 28 | require.NoError(t, err) 29 | assert.IsType(t, &rangedbtest.ThingWasDone{}, actualRecord.Data) 30 | } 31 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # RangeDB Examples 2 | 3 | ## Code Examples 4 | 5 | * Go 6 | * [Chat App](./chat) 7 | 8 | --- 9 | 10 | ## API Examples 11 | 12 | ### HTTP API 13 | 14 | https://pkg.go.dev/github.com/inklabs/rangedb/pkg/rangedbapi 15 | 16 | * [Get All Events](../pkg/rangedbapi/get_all_events_test.go) 17 | * [Get Events by Aggregate Type](../pkg/rangedbapi/get_events_by_aggregate_type_test.go) 18 | * [Get Events by Multiple Aggregate Types](../pkg/rangedbapi/get_events_by_aggregate_types_test.go) 19 | * [Get Events by Stream](../pkg/rangedbapi/get_events_by_stream_test.go) 20 | * [Get Events by Stream as Newline Delimited JSON](../pkg/rangedbapi/get_events_by_stream_ndjson_test.go) 21 | * [Save Events](../pkg/rangedbapi/save_events_test.go) 22 | * [Optimistic Concurrency](../pkg/rangedbapi/save_events_with_optimistic_concurrency_test.go) 23 | * [Optimistic Concurrency Failure Response](../pkg/rangedbapi/save_events_with_optimistic_concurrency_failure_test.go) 24 | * Delete Event Stream 25 | * [Optimistic Concurrency](../pkg/rangedbapi/delete_stream_with_optimistic_concurrency_test.go) 26 | * [Optimistic Concurrency Failure Response](../pkg/rangedbapi/delete_stream_with_optimistic_concurrency_failure_test.go) 27 | 28 | ### Websocket API 29 | 30 | https://pkg.go.dev/github.com/inklabs/rangedb/pkg/rangedbws 31 | 32 | * [Stream All Events](../pkg/rangedbws/stream_all_events_test.go) 33 | * [Stream Events by Aggregate Type](../pkg/rangedbws/stream_events_by_aggregate_type_test.go) 34 | 35 | ### gRPC 36 | 37 | https://pkg.go.dev/github.com/inklabs/rangedb/pkg/grpc/rangedbserver 38 | 39 | * [Get All Events](../pkg/grpc/rangedbserver/get_all_events_test.go) 40 | * [Get Events by Stream](../pkg/grpc/rangedbserver/get_events_by_stream_test.go) 41 | * [Get Events by Aggregate Type(s)](../pkg/grpc/rangedbserver/get_events_by_aggregate_types_test.go) 42 | * [Subscribe to All Events](../pkg/grpc/rangedbserver/subscribe_all_events_test.go) 43 | * [Subscribe to Events By Aggregate Type(s)](../pkg/grpc/rangedbserver/subscribe_events_by_aggregate_type_test.go) 44 | * [Save Events](../pkg/grpc/rangedbserver/save_test.go) 45 | * [Failure Response](../pkg/grpc/rangedbserver/save_failure_response_test.go) 46 | * [Optimistic Save Events](../pkg/grpc/rangedbserver/optimistic_save_test.go) 47 | * [Failure Response](../pkg/grpc/rangedbserver/optimistic_save_failure_test.go) 48 | * Delete Event Stream 49 | * [Optimistic Concurrency](../pkg/grpc/rangedbserver/optimistic_delete_stream_test.go) 50 | * [Optimistic Concurrency Failure Response](../pkg/grpc/rangedbserver/optimistic_delete_stream_failure_test.go) 51 | 52 | --- 53 | 54 | ## GDPR Examples 55 | 56 | ### Crypto-shredding 57 | 58 | https://verraes.net/2019/05/eventsourcing-patterns-throw-away-the-key/ 59 | 60 | * [Encrypt/Decrypt Event](../pkg/crypto/eventencryptor/encrypt_event_test.go) 61 | * [Delete Encryption Key](../pkg/crypto/eventencryptor/delete_encryption_key_test.go) 62 | * [Auto Encrypt/Decrypt Event with Decryption Store](../provider/encryptedstore/encrypt_event_test.go) 63 | -------------------------------------------------------------------------------- /examples/chat/README.md: -------------------------------------------------------------------------------- 1 | # Chat Application w/ CQRS + Event Sourcing 2 | 3 | Example chat application using the RangeDB library for CQRS and Event Sourcing. 4 | 5 | ## Event Model 6 | 7 | ![Chat Event Model](https://github.com/inklabs/rangedb/raw/master/examples/chat/doc/images/chat-event-model.jpg) 8 | -------------------------------------------------------------------------------- /examples/chat/bind_events_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-15 23:18:16.599746 -0800 PST m=+0.001241195 4 | package chat 5 | 6 | import "github.com/inklabs/rangedb" 7 | 8 | func BindEvents(binder rangedb.EventBinder) { 9 | binder.Bind( 10 | &RoomWasOnBoarded{}, 11 | &RoomWasJoined{}, 12 | &MessageWasSentToRoom{}, 13 | &PrivateMessageWasSentToRoom{}, 14 | &UserWasRemovedFromRoom{}, 15 | &UserWasBannedFromRoom{}, 16 | &UserWasOnBoarded{}, 17 | &UserWasWarned{}, 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /examples/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/cqrs" 8 | ) 9 | 10 | //go:generate go run ../../gen/eventbinder/main.go -files room_events.go,user_events.go 11 | 12 | // New constructs a new CQRS chat application that accepts commands to be dispatched. 13 | func New(store rangedb.Store) (cqrs.CommandDispatcher, error) { 14 | app := cqrs.New( 15 | store, 16 | cqrs.WithAggregates( 17 | NewUser(), 18 | NewRoom(), 19 | ), 20 | ) 21 | 22 | warnedUsers := NewWarnedUsersProjection() 23 | restrictedWordProcessor := newRestrictedWordProcessor(app, warnedUsers) 24 | 25 | ctx := context.Background() 26 | const bufferSize = 10 27 | 28 | // Block until all previous events have been read 29 | err := store.AllEventsSubscription(ctx, bufferSize, warnedUsers).StartFrom(0) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | // Subscribe to only new events 35 | err = store.AllEventsSubscription(ctx, bufferSize, restrictedWordProcessor).Start() 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return app, nil 41 | } 42 | -------------------------------------------------------------------------------- /examples/chat/doc/images/chat-event-model.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/rangedb/d4a1cabdbbd7f5654d2e7c79092c4e9b35b10f65/examples/chat/doc/images/chat-event-model.jpg -------------------------------------------------------------------------------- /examples/chat/restricted_word_processor.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/cqrs" 8 | ) 9 | 10 | const ( 11 | warnThreshold = 2 12 | banTimeout = 3600 13 | ) 14 | 15 | // RestrictedWords contains restricted words not allowed in this example chat application. 16 | var RestrictedWords = []string{"golly", "dagnabit", "gadzooks"} 17 | 18 | type restrictedWordProcessor struct { 19 | dispatcher cqrs.CommandDispatcher 20 | warnedUsers *warnedUsersProjection 21 | } 22 | 23 | func newRestrictedWordProcessor(d cqrs.CommandDispatcher, warnedUsers *warnedUsersProjection) *restrictedWordProcessor { 24 | return &restrictedWordProcessor{ 25 | dispatcher: d, 26 | warnedUsers: warnedUsers, 27 | } 28 | } 29 | 30 | // Accept receives a Record. 31 | func (r *restrictedWordProcessor) Accept(record *rangedb.Record) { 32 | switch e := record.Data.(type) { 33 | 34 | case *MessageWasSentToRoom: 35 | if containsRestrictedWord(e.Message) { 36 | if r.userWarningsExceedThreshold(e.UserID) { 37 | r.dispatcher.Dispatch(RemoveUserFromRoom{ 38 | UserID: e.UserID, 39 | RoomID: e.RoomID, 40 | Reason: "language", 41 | }) 42 | r.dispatcher.Dispatch(BanUserFromRoom{ 43 | UserID: e.UserID, 44 | RoomID: e.RoomID, 45 | Reason: "language", 46 | Timeout: banTimeout, 47 | }) 48 | return 49 | } 50 | 51 | r.dispatcher.Dispatch(SendPrivateMessageToRoom{ 52 | RoomID: e.RoomID, 53 | TargetUserID: e.UserID, 54 | Message: "you have been warned", 55 | }) 56 | r.dispatcher.Dispatch(WarnUser{ 57 | UserID: e.UserID, 58 | Reason: "language", 59 | }) 60 | } 61 | 62 | } 63 | } 64 | 65 | func (r *restrictedWordProcessor) userWarningsExceedThreshold(userID string) bool { 66 | return r.warnedUsers.TotalWarnings(userID) >= warnThreshold 67 | } 68 | 69 | func containsRestrictedWord(message string) bool { 70 | lowerMessage := strings.ToLower(message) 71 | 72 | for _, restrictedWord := range RestrictedWords { 73 | if strings.Contains(lowerMessage, restrictedWord) { 74 | return true 75 | } 76 | } 77 | 78 | return false 79 | } 80 | -------------------------------------------------------------------------------- /examples/chat/room_aggregate.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/aggregategenerator/main.go -name room 4 | 5 | import ( 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | type room struct { 10 | state roomState 11 | pendingEvents []rangedb.Event 12 | } 13 | 14 | type roomState struct { 15 | RoomName string 16 | IsOnBoarded bool 17 | BannedUsers map[string]struct{} 18 | } 19 | 20 | // NewRoom constructs a new cqrs.Aggregate. 21 | func NewRoom() *room { 22 | return &room{ 23 | state: roomState{ 24 | BannedUsers: make(map[string]struct{}), 25 | }, 26 | } 27 | } 28 | 29 | func (a *room) roomWasOnBoarded(e RoomWasOnBoarded) { 30 | a.state.IsOnBoarded = true 31 | a.state.RoomName = e.RoomName 32 | } 33 | 34 | func (a *room) userWasBannedFromRoom(e UserWasBannedFromRoom) { 35 | a.state.BannedUsers[e.UserID] = struct{}{} 36 | } 37 | 38 | func (a *room) onBoardRoom(c OnBoardRoom) { 39 | a.raise(RoomWasOnBoarded{ 40 | RoomID: c.RoomID, 41 | UserID: c.UserID, 42 | RoomName: c.RoomName, 43 | }) 44 | } 45 | 46 | func (a *room) joinRoom(c JoinRoom) { 47 | if !a.state.IsOnBoarded { 48 | return 49 | } 50 | 51 | if a.userIsBanned(c.UserID) { 52 | return 53 | } 54 | 55 | a.raise(RoomWasJoined{ 56 | RoomID: c.RoomID, 57 | UserID: c.UserID, 58 | }) 59 | } 60 | 61 | func (a *room) sendMessageToRoom(c SendMessageToRoom) { 62 | if !a.state.IsOnBoarded { 63 | return 64 | } 65 | 66 | a.raise(MessageWasSentToRoom{ 67 | RoomID: c.RoomID, 68 | UserID: c.UserID, 69 | Message: c.Message, 70 | }) 71 | } 72 | 73 | func (a *room) sendPrivateMessageToRoom(c SendPrivateMessageToRoom) { 74 | if !a.state.IsOnBoarded { 75 | return 76 | } 77 | 78 | a.raise(PrivateMessageWasSentToRoom{ 79 | RoomID: c.RoomID, 80 | TargetUserID: c.TargetUserID, 81 | Message: c.Message, 82 | }) 83 | } 84 | 85 | func (a *room) banUserFromRoom(c BanUserFromRoom) { 86 | a.raise(UserWasBannedFromRoom{ 87 | RoomID: c.RoomID, 88 | UserID: c.UserID, 89 | Reason: c.Reason, 90 | Timeout: c.Timeout, 91 | }) 92 | } 93 | 94 | func (a *room) removeUserFromRoom(c RemoveUserFromRoom) { 95 | a.raise(UserWasRemovedFromRoom{ 96 | RoomID: c.RoomID, 97 | UserID: c.UserID, 98 | Reason: c.Reason, 99 | }) 100 | } 101 | 102 | func (a *room) userIsBanned(userID string) bool { 103 | _, ok := a.state.BannedUsers[userID] 104 | return ok 105 | } 106 | 107 | func (a *room) roomWasJoined(_ RoomWasJoined) {} 108 | func (a *room) messageWasSentToRoom(_ MessageWasSentToRoom) {} 109 | func (a *room) privateMessageWasSentToRoom(_ PrivateMessageWasSentToRoom) {} 110 | func (a *room) userWasRemovedFromRoom(_ UserWasRemovedFromRoom) {} 111 | -------------------------------------------------------------------------------- /examples/chat/room_commands.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/commandgenerator/main.go -id RoomID -aggregateType room 4 | 5 | type OnBoardRoom struct { 6 | RoomID string `json:"roomID"` 7 | UserID string `json:"userID"` 8 | RoomName string `json:"roomName"` 9 | } 10 | 11 | type JoinRoom struct { 12 | RoomID string `json:"roomID"` 13 | UserID string `json:"userID"` 14 | } 15 | 16 | type SendMessageToRoom struct { 17 | RoomID string `json:"roomID"` 18 | UserID string `json:"userID"` 19 | Message string `json:"message"` 20 | } 21 | 22 | type SendPrivateMessageToRoom struct { 23 | RoomID string `json:"roomID"` 24 | TargetUserID string `json:"userID"` 25 | Message string `json:"message"` 26 | } 27 | 28 | type RemoveUserFromRoom struct { 29 | RoomID string `json:"roomID"` 30 | UserID string `json:"userID"` 31 | Reason string `json:"reason"` 32 | } 33 | 34 | type BanUserFromRoom struct { 35 | RoomID string `json:"roomID"` 36 | UserID string `json:"userID"` 37 | Reason string `json:"reason"` 38 | Timeout uint `json:"timeout"` 39 | } 40 | -------------------------------------------------------------------------------- /examples/chat/room_commands_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-30 11:27:48.039104 -0800 PST m=+0.001821984 4 | package chat 5 | 6 | func (c OnBoardRoom) AggregateID() string { return c.RoomID } 7 | func (c OnBoardRoom) AggregateType() string { return "room" } 8 | func (c OnBoardRoom) CommandType() string { return "OnBoardRoom" } 9 | 10 | func (c JoinRoom) AggregateID() string { return c.RoomID } 11 | func (c JoinRoom) AggregateType() string { return "room" } 12 | func (c JoinRoom) CommandType() string { return "JoinRoom" } 13 | 14 | func (c SendMessageToRoom) AggregateID() string { return c.RoomID } 15 | func (c SendMessageToRoom) AggregateType() string { return "room" } 16 | func (c SendMessageToRoom) CommandType() string { return "SendMessageToRoom" } 17 | 18 | func (c SendPrivateMessageToRoom) AggregateID() string { return c.RoomID } 19 | func (c SendPrivateMessageToRoom) AggregateType() string { return "room" } 20 | func (c SendPrivateMessageToRoom) CommandType() string { return "SendPrivateMessageToRoom" } 21 | 22 | func (c RemoveUserFromRoom) AggregateID() string { return c.RoomID } 23 | func (c RemoveUserFromRoom) AggregateType() string { return "room" } 24 | func (c RemoveUserFromRoom) CommandType() string { return "RemoveUserFromRoom" } 25 | 26 | func (c BanUserFromRoom) AggregateID() string { return c.RoomID } 27 | func (c BanUserFromRoom) AggregateType() string { return "room" } 28 | func (c BanUserFromRoom) CommandType() string { return "BanUserFromRoom" } 29 | -------------------------------------------------------------------------------- /examples/chat/room_events.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/eventgenerator/main.go -id RoomID -aggregateType room 4 | 5 | type RoomWasOnBoarded struct { 6 | RoomID string `json:"roomID"` 7 | UserID string `json:"userID"` 8 | RoomName string `json:"roomName"` 9 | } 10 | 11 | type RoomWasJoined struct { 12 | RoomID string `json:"roomID"` 13 | UserID string `json:"userID"` 14 | } 15 | 16 | type MessageWasSentToRoom struct { 17 | RoomID string `json:"roomID"` 18 | UserID string `json:"userID"` 19 | Message string `json:"message"` 20 | } 21 | 22 | type PrivateMessageWasSentToRoom struct { 23 | RoomID string `json:"roomID"` 24 | TargetUserID string `json:"userID"` 25 | Message string `json:"message"` 26 | } 27 | 28 | type UserWasRemovedFromRoom struct { 29 | RoomID string `json:"roomID"` 30 | UserID string `json:"userID"` 31 | Reason string `json:"reason"` 32 | } 33 | 34 | type UserWasBannedFromRoom struct { 35 | RoomID string `json:"roomID"` 36 | UserID string `json:"userID"` 37 | Reason string `json:"reason"` 38 | Timeout uint `json:"timeout"` 39 | } 40 | -------------------------------------------------------------------------------- /examples/chat/room_events_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-15 23:18:17.029174 -0800 PST m=+0.003738011 4 | package chat 5 | 6 | func (e RoomWasOnBoarded) AggregateID() string { return e.RoomID } 7 | func (e RoomWasOnBoarded) AggregateType() string { return "room" } 8 | func (e RoomWasOnBoarded) EventType() string { return "RoomWasOnBoarded" } 9 | 10 | func (e RoomWasJoined) AggregateID() string { return e.RoomID } 11 | func (e RoomWasJoined) AggregateType() string { return "room" } 12 | func (e RoomWasJoined) EventType() string { return "RoomWasJoined" } 13 | 14 | func (e MessageWasSentToRoom) AggregateID() string { return e.RoomID } 15 | func (e MessageWasSentToRoom) AggregateType() string { return "room" } 16 | func (e MessageWasSentToRoom) EventType() string { return "MessageWasSentToRoom" } 17 | 18 | func (e PrivateMessageWasSentToRoom) AggregateID() string { return e.RoomID } 19 | func (e PrivateMessageWasSentToRoom) AggregateType() string { return "room" } 20 | func (e PrivateMessageWasSentToRoom) EventType() string { return "PrivateMessageWasSentToRoom" } 21 | 22 | func (e UserWasRemovedFromRoom) AggregateID() string { return e.RoomID } 23 | func (e UserWasRemovedFromRoom) AggregateType() string { return "room" } 24 | func (e UserWasRemovedFromRoom) EventType() string { return "UserWasRemovedFromRoom" } 25 | 26 | func (e UserWasBannedFromRoom) AggregateID() string { return e.RoomID } 27 | func (e UserWasBannedFromRoom) AggregateType() string { return "room" } 28 | func (e UserWasBannedFromRoom) EventType() string { return "UserWasBannedFromRoom" } 29 | -------------------------------------------------------------------------------- /examples/chat/user_aggregate.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/aggregategenerator/main.go -name user 4 | 5 | import ( 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | type user struct { 10 | state userState 11 | pendingEvents []rangedb.Event 12 | } 13 | 14 | type userState struct { 15 | IsOnBoarded bool 16 | Name string 17 | } 18 | 19 | // NewUser constructs a new cqrs.Aggregate. 20 | func NewUser() *user { 21 | return &user{} 22 | } 23 | 24 | func (a *user) userWasOnBoarded(e UserWasOnBoarded) { 25 | a.state.IsOnBoarded = true 26 | a.state.Name = e.Name 27 | } 28 | 29 | func (a *user) onBoardUser(c OnBoardUser) { 30 | if a.state.IsOnBoarded { 31 | return 32 | } 33 | 34 | a.raise(UserWasOnBoarded{ 35 | UserID: c.UserID, 36 | Name: c.Name, 37 | }) 38 | } 39 | 40 | func (a *user) warnUser(c WarnUser) { 41 | a.raise(UserWasWarned{ 42 | UserID: c.UserID, 43 | Reason: c.Reason, 44 | }) 45 | } 46 | 47 | func (a *user) userWasWarned(_ UserWasWarned) {} 48 | -------------------------------------------------------------------------------- /examples/chat/user_aggregate_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-11-19 15:31:41.120391 -0800 PST m=+0.004059159 4 | package chat 5 | 6 | import ( 7 | "github.com/inklabs/rangedb" 8 | "github.com/inklabs/rangedb/pkg/cqrs" 9 | ) 10 | 11 | func (a *user) Load(recordIterator rangedb.RecordIterator) { 12 | for recordIterator.Next() { 13 | if recordIterator.Err() == nil { 14 | if event, ok := recordIterator.Record().Data.(rangedb.Event); ok { 15 | a.apply(event) 16 | } 17 | } 18 | } 19 | } 20 | 21 | func (a *user) apply(event rangedb.Event) { 22 | switch e := event.(type) { 23 | 24 | case UserWasOnBoarded: 25 | a.userWasOnBoarded(e) 26 | 27 | case *UserWasOnBoarded: 28 | a.userWasOnBoarded(*e) 29 | 30 | case UserWasWarned: 31 | a.userWasWarned(e) 32 | 33 | case *UserWasWarned: 34 | a.userWasWarned(*e) 35 | 36 | } 37 | } 38 | 39 | func (a *user) Handle(command cqrs.Command) []rangedb.Event { 40 | switch c := command.(type) { 41 | 42 | case OnBoardUser: 43 | a.onBoardUser(c) 44 | 45 | case *OnBoardUser: 46 | a.onBoardUser(*c) 47 | 48 | case WarnUser: 49 | a.warnUser(c) 50 | 51 | case *WarnUser: 52 | a.warnUser(*c) 53 | 54 | } 55 | 56 | defer a.resetPendingEvents() 57 | return a.pendingEvents 58 | } 59 | 60 | func (a *user) resetPendingEvents() { 61 | a.pendingEvents = nil 62 | } 63 | 64 | func (a *user) CommandTypes() []string { 65 | return []string{ 66 | OnBoardUser{}.CommandType(), 67 | WarnUser{}.CommandType(), 68 | } 69 | } 70 | 71 | func (a *user) raise(events ...rangedb.Event) { 72 | a.pendingEvents = append(a.pendingEvents, events...) 73 | } 74 | -------------------------------------------------------------------------------- /examples/chat/user_commands.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/commandgenerator/main.go -id UserID -aggregateType user 4 | 5 | type OnBoardUser struct { 6 | UserID string `json:"userID"` 7 | Name string `json:"name"` 8 | } 9 | 10 | type WarnUser struct { 11 | UserID string `json:"userID"` 12 | Reason string `json:"reason"` 13 | } 14 | -------------------------------------------------------------------------------- /examples/chat/user_commands_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-30 11:29:08.177616 -0800 PST m=+0.001769886 4 | package chat 5 | 6 | func (c OnBoardUser) AggregateID() string { return c.UserID } 7 | func (c OnBoardUser) AggregateType() string { return "user" } 8 | func (c OnBoardUser) CommandType() string { return "OnBoardUser" } 9 | 10 | func (c WarnUser) AggregateID() string { return c.UserID } 11 | func (c WarnUser) AggregateType() string { return "user" } 12 | func (c WarnUser) CommandType() string { return "WarnUser" } 13 | -------------------------------------------------------------------------------- /examples/chat/user_events.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | //go:generate go run ../../gen/eventgenerator/main.go -id UserID -aggregateType user 4 | 5 | type UserWasOnBoarded struct { 6 | UserID string `json:"userID"` 7 | Name string `json:"name"` 8 | } 9 | 10 | type UserWasWarned struct { 11 | UserID string `json:"userID"` 12 | Reason string `json:"reason"` 13 | } 14 | -------------------------------------------------------------------------------- /examples/chat/user_events_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-15 23:18:17.37513 -0800 PST m=+0.005275011 4 | package chat 5 | 6 | func (e UserWasOnBoarded) AggregateID() string { return e.UserID } 7 | func (e UserWasOnBoarded) AggregateType() string { return "user" } 8 | func (e UserWasOnBoarded) EventType() string { return "UserWasOnBoarded" } 9 | 10 | func (e UserWasWarned) AggregateID() string { return e.UserID } 11 | func (e UserWasWarned) AggregateType() string { return "user" } 12 | func (e UserWasWarned) EventType() string { return "UserWasWarned" } 13 | -------------------------------------------------------------------------------- /examples/chat/warned_users_projection.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | type warnedUsersProjection struct { 10 | mux sync.RWMutex 11 | userWarnings map[string]uint 12 | } 13 | 14 | // NewWarnedUsersProjection constructs a projection for tracking the number of warnings by user. 15 | func NewWarnedUsersProjection() *warnedUsersProjection { 16 | return &warnedUsersProjection{ 17 | userWarnings: make(map[string]uint), 18 | } 19 | } 20 | 21 | // TotalWarnings returns the total number of warnings by userID. 22 | func (u *warnedUsersProjection) TotalWarnings(userID string) uint { 23 | u.mux.RLock() 24 | defer u.mux.RUnlock() 25 | if totalWarnings, ok := u.userWarnings[userID]; ok { 26 | return totalWarnings 27 | } 28 | 29 | return 0 30 | } 31 | 32 | // Accept receives a Record. 33 | func (u *warnedUsersProjection) Accept(record *rangedb.Record) { 34 | switch e := record.Data.(type) { 35 | 36 | case *UserWasWarned: 37 | u.mux.Lock() 38 | u.userWarnings[e.UserID]++ 39 | u.mux.Unlock() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/chat/warned_users_projection_test.go: -------------------------------------------------------------------------------- 1 | package chat_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/inklabs/rangedb/examples/chat" 9 | "github.com/inklabs/rangedb/rangedbtest" 10 | ) 11 | 12 | func TestWarnedUsersProjection(t *testing.T) { 13 | t.Run("returns 0 for no warnings for userID", func(t *testing.T) { 14 | // Given 15 | warnedUsers := chat.NewWarnedUsersProjection() 16 | 17 | // When 18 | totalWarnings := warnedUsers.TotalWarnings(userID) 19 | 20 | // Then 21 | assert.Equal(t, uint(0), totalWarnings) 22 | }) 23 | 24 | t.Run("returns 1 for single warning for userID", func(t *testing.T) { 25 | // Given 26 | warnedUsers := chat.NewWarnedUsersProjection() 27 | record := rangedbtest.DummyRecordFromEvent(&chat.UserWasWarned{UserID: userID, Reason: "language"}) 28 | warnedUsers.Accept(record) 29 | 30 | // When 31 | totalWarnings := warnedUsers.TotalWarnings(userID) 32 | 33 | // Then 34 | assert.Equal(t, uint(1), totalWarnings) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /gen/aggregategenerator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/inklabs/rangedb/pkg/aggregategenerator" 10 | "github.com/inklabs/rangedb/pkg/structparser" 11 | ) 12 | 13 | func main() { 14 | pkg := flag.String("package", os.Getenv("GOPACKAGE"), "package") 15 | aggregateName := flag.String("name", "", "aggregate name") 16 | commandsPath := flag.String("commands", "", "filename containing commands") 17 | eventsPath := flag.String("events", "", "filename containing events") 18 | outFilePath := flag.String("out", "", "output file name") 19 | flag.Parse() 20 | 21 | if *outFilePath == "" { 22 | *outFilePath = fmt.Sprintf("%s_aggregate_gen.go", *aggregateName) 23 | } 24 | 25 | onlyAggregateNameProvided := *aggregateName != "" && *commandsPath == "" && *eventsPath == "" 26 | if onlyAggregateNameProvided { 27 | *commandsPath = fmt.Sprintf("%s_commands.go", *aggregateName) 28 | *eventsPath = fmt.Sprintf("%s_events.go", *aggregateName) 29 | } 30 | 31 | commands := structNamesFromPath(*commandsPath) 32 | events := structNamesFromPath(*eventsPath) 33 | 34 | outFile, err := os.Create(*outFilePath) 35 | if err != nil { 36 | log.Fatalf("unable to create aggreate file: %v", err) 37 | } 38 | defer func() { 39 | _ = outFile.Close() 40 | }() 41 | 42 | err = aggregategenerator.Write(outFile, *pkg, *aggregateName, commands, events) 43 | if err != nil { 44 | log.Fatalf("unable to write to aggregates file: %v", err) 45 | } 46 | } 47 | 48 | func structNamesFromPath(path string) []string { 49 | var structNames []string 50 | if path != "" { 51 | file, err := os.Open(path) 52 | if err != nil { 53 | log.Fatalf("unable to open (%s): %v", path, err) 54 | } 55 | defer func() { 56 | _ = file.Close() 57 | }() 58 | 59 | structNames, err = structparser.GetStructNames(file) 60 | if err != nil { 61 | log.Fatalf("unable to extract struct names: %v", err) 62 | } 63 | } 64 | 65 | return structNames 66 | } 67 | -------------------------------------------------------------------------------- /gen/commandgenerator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/inklabs/rangedb/pkg/commandgenerator" 12 | "github.com/inklabs/rangedb/pkg/structparser" 13 | ) 14 | 15 | func main() { 16 | pkg := flag.String("package", os.Getenv("GOPACKAGE"), "package") 17 | id := flag.String("id", "", "id") 18 | aggregateType := flag.String("aggregateType", "", "aggregate name") 19 | inFilePath := flag.String("inFile", os.Getenv("GOFILE"), "input filename containing commands") 20 | outFilePath := flag.String("outFile", "", "output file name") 21 | flag.Parse() 22 | 23 | if *outFilePath == "" { 24 | if *outFilePath == "" { 25 | fileName := strings.TrimSuffix(*inFilePath, path.Ext(*inFilePath)) 26 | *outFilePath = fmt.Sprintf("%s_gen.go", fileName) 27 | } 28 | } 29 | 30 | commandsFile, err := os.Open(*inFilePath) 31 | if err != nil { 32 | log.Fatalf("unable to open (%s): %v", *inFilePath, err) 33 | } 34 | defer func() { 35 | _ = commandsFile.Close() 36 | }() 37 | 38 | commands, err := structparser.GetStructNames(commandsFile) 39 | if err != nil { 40 | log.Fatalf("unable to extract commands: %v", err) 41 | } 42 | 43 | outFile, err := os.Create(*outFilePath) 44 | if err != nil { 45 | log.Fatalf("unable to create aggreate file: %v", err) 46 | } 47 | defer func() { 48 | _ = outFile.Close() 49 | }() 50 | 51 | err = commandgenerator.Write(outFile, commands, *pkg, *id, *aggregateType) 52 | if err != nil { 53 | log.Fatalf("unable to write to aggregates file: %v", err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gen/eventbinder/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "log" 7 | "os" 8 | "strings" 9 | "text/template" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb/pkg/structparser" 13 | ) 14 | 15 | func main() { 16 | packageName := flag.String("package", os.Getenv("GOPACKAGE"), "package") 17 | fileNamesCSV := flag.String("files", "", "comma separated filenames containing events") 18 | outFilePath := flag.String("out", "bind_events_gen.go", "output file name") 19 | flag.Parse() 20 | 21 | fileNames := strings.Split(*fileNamesCSV, ",") 22 | 23 | var eventNames []string 24 | for _, fileName := range fileNames { 25 | file, err := os.Open(fileName) 26 | if err != nil { 27 | log.Fatalf("unable to open (%s): %v", fileName, err) 28 | } 29 | 30 | structNames, err := structparser.GetStructNames(file) 31 | if err != nil { 32 | log.Fatalf("unable to extract events: %v", err) 33 | } 34 | 35 | eventNames = append(eventNames, structNames...) 36 | } 37 | 38 | writeEventBinder(eventNames, *packageName, *outFilePath) 39 | } 40 | 41 | func writeEventBinder(eventNames []string, packageName, outputFilePath string) { 42 | file, err := os.Create(outputFilePath) 43 | if err != nil { 44 | log.Fatalf("unable to create events file: %v", err) 45 | } 46 | defer closeOrFail(file) 47 | 48 | err = fileTemplate.Execute(file, templateData{ 49 | Timestamp: time.Now(), 50 | PackageName: packageName, 51 | EventNames: eventNames, 52 | }) 53 | if err != nil { 54 | log.Fatalf("unable to write to events file: %v", err) 55 | } 56 | } 57 | 58 | func closeOrFail(c io.Closer) { 59 | err := c.Close() 60 | if err != nil { 61 | log.Fatalf("failed closing: %v", err) 62 | } 63 | } 64 | 65 | type templateData struct { 66 | Timestamp time.Time 67 | PackageName string 68 | EventNames []string 69 | } 70 | 71 | var fileTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. 72 | // This file was generated at 73 | // {{ .Timestamp }} 74 | package {{ .PackageName }} 75 | 76 | import "github.com/inklabs/rangedb" 77 | 78 | func BindEvents(binder rangedb.EventBinder) { 79 | binder.Bind({{ range .EventNames }} 80 | &{{ . }}{},{{ end }} 81 | ) 82 | } 83 | `)) 84 | -------------------------------------------------------------------------------- /gen/eventgenerator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/inklabs/rangedb/pkg/eventparser" 12 | ) 13 | 14 | func main() { 15 | pkg := flag.String("package", os.Getenv("GOPACKAGE"), "package") 16 | id := flag.String("id", "", "id") 17 | aggregateType := flag.String("aggregateType", "", "stream identifier") 18 | inFilePath := flag.String("inFile", os.Getenv("GOFILE"), "input filename containing structs") 19 | outFilePath := flag.String("outFile", "", "output filename containing generated struct methods") 20 | flag.Parse() 21 | 22 | file, err := os.Open(*inFilePath) 23 | if err != nil { 24 | log.Fatalf("unable to open (%s): %v", *inFilePath, err) 25 | } 26 | 27 | if *outFilePath == "" { 28 | fileName := strings.TrimSuffix(*inFilePath, path.Ext(*inFilePath)) 29 | *outFilePath = fmt.Sprintf("%s_gen.go", fileName) 30 | } 31 | 32 | events, err := eventparser.GetEvents(file) 33 | if err != nil { 34 | log.Fatalf("unable to extract events: %v", err) 35 | } 36 | 37 | _ = file.Close() 38 | 39 | outFile, err := os.Create(*outFilePath) 40 | if err != nil { 41 | log.Fatalf("unable to create events file: %v", err) 42 | } 43 | 44 | err = eventparser.WriteEvents(outFile, events, *pkg, *id, *aggregateType) 45 | if err != nil { 46 | log.Fatalf("unable to write to events file: %v", err) 47 | } 48 | 49 | _ = outFile.Close() 50 | } 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/inklabs/rangedb 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/EventStore/EventStore-Client-Go v1.0.2 7 | github.com/aws/aws-sdk-go v1.38.22 8 | github.com/dustin/go-humanize v1.0.0 9 | github.com/fatih/structtag v1.2.0 10 | github.com/gofrs/uuid v4.0.0+incompatible 11 | github.com/golang/protobuf v1.5.0 12 | github.com/google/uuid v1.1.2 13 | github.com/gorilla/handlers v1.4.2 14 | github.com/gorilla/mux v1.7.4 15 | github.com/gorilla/websocket v1.4.2 16 | github.com/lib/pq v1.8.0 17 | github.com/opencontainers/image-spec v1.0.2 // indirect 18 | github.com/opencontainers/runc v1.0.3 // indirect 19 | github.com/stretchr/testify v1.7.0 20 | github.com/syndtr/goleveldb v1.0.0 21 | github.com/vmihailenco/msgpack/v4 v4.3.11 22 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 23 | google.golang.org/appengine v1.6.6 // indirect 24 | google.golang.org/grpc v1.35.0 25 | google.golang.org/protobuf v1.27.1 26 | gopkg.in/yaml.v2 v2.3.0 // indirect 27 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /merge_record_iterators.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | type pipe struct { 4 | recordIterator RecordIterator 5 | currentResultRecord ResultRecord 6 | } 7 | 8 | func newPipe(recordIterator RecordIterator) *pipe { 9 | return &pipe{recordIterator: recordIterator} 10 | } 11 | 12 | func (p *pipe) ReadNext() bool { 13 | p.recordIterator.Next() 14 | p.currentResultRecord = ResultRecord{ 15 | Record: p.recordIterator.Record(), 16 | Err: p.recordIterator.Err(), 17 | } 18 | return p.currentResultRecord.Record != nil 19 | } 20 | 21 | func (p *pipe) IsNextGlobalSequenceNumber(currentPosition uint64) bool { 22 | return p.currentResultRecord.Record.GlobalSequenceNumber == currentPosition+1 23 | } 24 | 25 | // MergeRecordIteratorsInOrder combines record channels ordered by record.GlobalSequenceNumber. 26 | func MergeRecordIteratorsInOrder(recordIterators []RecordIterator) RecordIterator { 27 | resultRecords := make(chan ResultRecord) 28 | 29 | go func() { 30 | defer close(resultRecords) 31 | 32 | var pipes []*pipe 33 | for _, recordIterator := range recordIterators { 34 | pipe := newPipe(recordIterator) 35 | if pipe.ReadNext() { 36 | pipes = append(pipes, pipe) 37 | } 38 | } 39 | 40 | var currentPosition uint64 41 | 42 | for len(pipes) > 0 { 43 | i := getIndexWithSmallestGlobalSequenceNumber(pipes) 44 | 45 | resultRecords <- pipes[i].currentResultRecord 46 | 47 | currentPosition = pipes[i].currentResultRecord.Record.GlobalSequenceNumber 48 | 49 | if !pipes[i].ReadNext() { 50 | pipes = remove(pipes, i) 51 | continue 52 | } 53 | 54 | for pipes[i].IsNextGlobalSequenceNumber(currentPosition) { 55 | resultRecords <- pipes[i].currentResultRecord 56 | 57 | currentPosition = pipes[i].currentResultRecord.Record.GlobalSequenceNumber 58 | 59 | if !pipes[i].ReadNext() { 60 | pipes = remove(pipes, i) 61 | break 62 | } 63 | } 64 | } 65 | }() 66 | 67 | return NewRecordIterator(resultRecords) 68 | } 69 | 70 | func remove(s []*pipe, i int) []*pipe { 71 | s[i] = s[len(s)-1] 72 | return s[:len(s)-1] 73 | } 74 | 75 | func getIndexWithSmallestGlobalSequenceNumber(pipes []*pipe) int { 76 | smallestIndex := 0 77 | min := pipes[smallestIndex].currentResultRecord.Record.GlobalSequenceNumber 78 | for i, pipe := range pipes { 79 | if pipe.currentResultRecord.Record.GlobalSequenceNumber < min { 80 | smallestIndex = i 81 | min = pipe.currentResultRecord.Record.GlobalSequenceNumber 82 | } 83 | } 84 | return smallestIndex 85 | } 86 | -------------------------------------------------------------------------------- /pkg/aggregategenerator/write_aggregate.go: -------------------------------------------------------------------------------- 1 | package aggregategenerator 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "text/template" 7 | "time" 8 | "unicode" 9 | ) 10 | 11 | var NowFunc = time.Now 12 | 13 | // Write generates the boilerplate aggregate Go code required for the cqrs package. 14 | func Write(out io.Writer, pkg, aggregateName string, commands, events []string) error { 15 | if len(commands)+len(events) < 1 { 16 | return fmt.Errorf("no commands or events found") 17 | } 18 | 19 | return fileTemplate.Execute(out, templateData{ 20 | Timestamp: NowFunc(), 21 | Commands: commands, 22 | Events: events, 23 | AggregateName: aggregateName, 24 | Package: pkg, 25 | }) 26 | } 27 | 28 | type templateData struct { 29 | Timestamp time.Time 30 | Commands []string 31 | Events []string 32 | AggregateName string 33 | Package string 34 | } 35 | 36 | func lcFirst(str string) string { 37 | for i, v := range str { 38 | return string(unicode.ToLower(v)) + str[i+1:] 39 | } 40 | return "" 41 | } 42 | 43 | var funcMap = template.FuncMap{ 44 | "LcFirst": lcFirst, 45 | } 46 | 47 | var fileTemplate = template.Must(template.New("").Funcs(funcMap).Parse(`// Code generated by go generate; DO NOT EDIT. 48 | // This file was generated at 49 | // {{ .Timestamp }} 50 | package {{ $.Package }} 51 | 52 | import ( 53 | "github.com/inklabs/rangedb" 54 | "github.com/inklabs/rangedb/pkg/cqrs" 55 | ) 56 | 57 | func (a *{{ .AggregateName }}) Load(recordIterator rangedb.RecordIterator) { 58 | for recordIterator.Next() { 59 | {{- if .Events }} 60 | if recordIterator.Err() == nil { 61 | if event, ok := recordIterator.Record().Data.(rangedb.Event); ok { 62 | a.apply(event) 63 | } 64 | } 65 | {{- end }} 66 | } 67 | } 68 | 69 | {{- if .Events }} 70 | 71 | func (a *{{ .AggregateName }}) apply(event rangedb.Event) { 72 | switch e := event.(type) { 73 | {{- range .Events }} 74 | 75 | case {{ . }}: 76 | a.{{ . | LcFirst }}(e) 77 | 78 | case *{{ . }}: 79 | a.{{ . | LcFirst }}(*e) 80 | {{- end }} 81 | 82 | } 83 | } 84 | {{- end }} 85 | 86 | func (a *{{ .AggregateName }}) Handle(command cqrs.Command) []rangedb.Event { 87 | {{- if .Commands }} 88 | switch c := command.(type) { 89 | {{- range .Commands }} 90 | 91 | case {{ . }}: 92 | a.{{ . | LcFirst }}(c) 93 | 94 | case *{{ . }}: 95 | a.{{ . | LcFirst }}(*c) 96 | {{- end }} 97 | 98 | } 99 | {{ end }} 100 | defer a.resetPendingEvents() 101 | return a.pendingEvents 102 | } 103 | 104 | func (a *{{ .AggregateName }}) resetPendingEvents() { 105 | a.pendingEvents = nil 106 | } 107 | 108 | func (a *{{ .AggregateName }}) CommandTypes() []string { 109 | return []string{ 110 | {{- range .Commands }} 111 | {{ . }}{}.CommandType(), 112 | {{- end }} 113 | } 114 | } 115 | 116 | func (a *{{ .AggregateName }}) raise(events ...rangedb.Event) { 117 | a.pendingEvents = append(a.pendingEvents, events...) 118 | } 119 | `)) 120 | -------------------------------------------------------------------------------- /pkg/clock/clock.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Clock is the interface that defines a method to get the current time. 8 | type Clock interface { 9 | Now() time.Time 10 | } 11 | -------------------------------------------------------------------------------- /pkg/clock/provider/seededclock/seeded_clock.go: -------------------------------------------------------------------------------- 1 | package seededclock 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type seededClock struct { 8 | times []time.Time 9 | index int 10 | } 11 | 12 | // New constructs a seeded clock. 13 | func New(times ...time.Time) *seededClock { 14 | if len(times) == 0 { 15 | times = []time.Time{time.Unix(0, 0)} 16 | } 17 | 18 | return &seededClock{ 19 | times: times, 20 | } 21 | } 22 | 23 | func (s *seededClock) Now() time.Time { 24 | if s.index >= len(s.times) { 25 | s.index = 0 26 | } 27 | 28 | index := s.index 29 | s.index++ 30 | return s.times[index] 31 | } 32 | -------------------------------------------------------------------------------- /pkg/clock/provider/seededclock/seeded_clock_test.go: -------------------------------------------------------------------------------- 1 | package seededclock_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/inklabs/rangedb/pkg/clock/provider/seededclock" 11 | ) 12 | 13 | func Test_SeededClock_EmptyConstructor_ReturnsZero(t *testing.T) { 14 | // Given 15 | clock := seededclock.New() 16 | 17 | // When 18 | actualTime := clock.Now() 19 | 20 | // Then 21 | assert.Equal(t, 0, int(actualTime.Unix())) 22 | } 23 | 24 | func Test_EmptyConstructor_SeedsWithZero(t *testing.T) { 25 | // Given 26 | clock := seededclock.New() 27 | 28 | // Then 29 | assert.Equal(t, 0, int(clock.Now().Unix())) 30 | assert.Equal(t, 0, int(clock.Now().Unix())) 31 | } 32 | 33 | func Test_WithSeed_RepeatsPattern(t *testing.T) { 34 | // Given 35 | clock := seededclock.New( 36 | time.Unix(1576828800, 0), 37 | time.Unix(1579507200, 0), 38 | ) 39 | 40 | // Then 41 | assert.Equal(t, 1576828800, int(clock.Now().Unix())) 42 | assert.Equal(t, 1579507200, int(clock.Now().Unix())) 43 | assert.Equal(t, 1576828800, int(clock.Now().Unix())) 44 | assert.Equal(t, 1579507200, int(clock.Now().Unix())) 45 | } 46 | 47 | func ExampleNew_withSeed() { 48 | // Given 49 | clock := seededclock.New( 50 | time.Unix(1576828800, 0), 51 | time.Unix(1579507200, 0), 52 | ) 53 | 54 | // When 55 | fmt.Println(clock.Now().Unix()) 56 | fmt.Println(clock.Now().Unix()) 57 | 58 | // Output: 59 | // 1576828800 60 | // 1579507200 61 | } 62 | -------------------------------------------------------------------------------- /pkg/clock/provider/sequentialclock/sequential_clock.go: -------------------------------------------------------------------------------- 1 | package sequentialclock 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type sequentialClock struct { 8 | seconds int64 9 | } 10 | 11 | // New constructs a sequential clock. 12 | func New() *sequentialClock { 13 | return &sequentialClock{} 14 | } 15 | 16 | func (c *sequentialClock) Now() time.Time { 17 | c.seconds++ 18 | return time.Unix(c.seconds-1, 0) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/clock/provider/sequentialclock/sequential_clock_test.go: -------------------------------------------------------------------------------- 1 | package sequentialclock_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 10 | ) 11 | 12 | func Test_SequentialClock(t *testing.T) { 13 | // Given 14 | clock := sequentialclock.New() 15 | 16 | // When 17 | actualTime := clock.Now() 18 | 19 | // Then 20 | assert.Equal(t, 0, int(actualTime.Unix())) 21 | } 22 | 23 | func ExampleNew() { 24 | // Given 25 | clock := sequentialclock.New() 26 | 27 | // When 28 | fmt.Println(clock.Now().Unix()) 29 | fmt.Println(clock.Now().Unix()) 30 | fmt.Println(clock.Now().Unix()) 31 | 32 | // Output: 33 | // 0 34 | // 1 35 | // 2 36 | } 37 | -------------------------------------------------------------------------------- /pkg/clock/provider/systemclock/system_clock.go: -------------------------------------------------------------------------------- 1 | package systemclock 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type systemClock struct{} 8 | 9 | func (s systemClock) Now() time.Time { 10 | return time.Now() 11 | } 12 | 13 | // New constructs a system clock. 14 | func New() *systemClock { 15 | return &systemClock{} 16 | } 17 | -------------------------------------------------------------------------------- /pkg/clock/provider/systemclock/system_clock_test.go: -------------------------------------------------------------------------------- 1 | package systemclock_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/inklabs/rangedb/pkg/clock/provider/systemclock" 9 | ) 10 | 11 | func Test_SystemClock(t *testing.T) { 12 | // Given 13 | clock := systemclock.New() 14 | 15 | // When 16 | actualTime := clock.Now() 17 | 18 | // Then 19 | assert.True(t, actualTime.Unix() > 0) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/commandgenerator/write_command.go: -------------------------------------------------------------------------------- 1 | package commandgenerator 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "text/template" 7 | "time" 8 | ) 9 | 10 | var NowFunc = time.Now 11 | 12 | func Write(out io.Writer, commands []string, pkg, id, aggregateType string) error { 13 | if len(commands) < 1 { 14 | return fmt.Errorf("no commands found") 15 | } 16 | 17 | return fileTemplate.Execute(out, templateData{ 18 | Timestamp: NowFunc(), 19 | Commands: commands, 20 | AggregateType: aggregateType, 21 | ID: id, 22 | Package: pkg, 23 | }) 24 | } 25 | 26 | type templateData struct { 27 | Timestamp time.Time 28 | Commands []string 29 | AggregateType string 30 | ID string 31 | Package string 32 | } 33 | 34 | var fileTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. 35 | // This file was generated at 36 | // {{ .Timestamp }} 37 | package {{ $.Package }} 38 | 39 | {{- range .Commands }} 40 | 41 | func (c {{ . }}) AggregateID() string { return c.{{ $.ID }} } 42 | func (c {{ . }}) AggregateType() string { return "{{ $.AggregateType }}" } 43 | func (c {{ . }}) CommandType() string { return "{{ . }}" } 44 | {{- end }} 45 | `)) 46 | -------------------------------------------------------------------------------- /pkg/commandgenerator/write_command_test.go: -------------------------------------------------------------------------------- 1 | package commandgenerator_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/inklabs/rangedb/pkg/commandgenerator" 12 | ) 13 | 14 | func TestWrite(t *testing.T) { 15 | const ( 16 | pkg = "mypkg" 17 | id = "ID" 18 | aggregateType = "thing" 19 | ) 20 | commandgenerator.NowFunc = func() time.Time { 21 | return time.Unix(1611080667, 25600800).UTC() 22 | } 23 | 24 | t.Run("no commands found", func(t *testing.T) { 25 | // Given 26 | var commands []string 27 | var out bytes.Buffer 28 | 29 | // When 30 | err := commandgenerator.Write(&out, commands, pkg, id, aggregateType) 31 | 32 | // Then 33 | require.EqualError(t, err, "no commands found") 34 | }) 35 | 36 | t.Run("single command", func(t *testing.T) { 37 | // Given 38 | commands := []string{ 39 | "DoThing", 40 | } 41 | var out bytes.Buffer 42 | 43 | // When 44 | err := commandgenerator.Write(&out, commands, pkg, id, aggregateType) 45 | 46 | // Then 47 | require.NoError(t, err) 48 | expectedOut := `// Code generated by go generate; DO NOT EDIT. 49 | // This file was generated at 50 | // 2021-01-19 18:24:27.0256008 +0000 UTC 51 | package mypkg 52 | 53 | func (c DoThing) AggregateID() string { return c.ID } 54 | func (c DoThing) AggregateType() string { return "thing" } 55 | func (c DoThing) CommandType() string { return "DoThing" } 56 | ` 57 | assert.Equal(t, expectedOut, out.String()) 58 | }) 59 | 60 | t.Run("two commands", func(t *testing.T) { 61 | // Given 62 | commands := []string{ 63 | "DoThing", 64 | "DoAnother", 65 | } 66 | var out bytes.Buffer 67 | 68 | // When 69 | err := commandgenerator.Write(&out, commands, pkg, id, aggregateType) 70 | 71 | // Then 72 | require.NoError(t, err) 73 | expectedOut := `// Code generated by go generate; DO NOT EDIT. 74 | // This file was generated at 75 | // 2021-01-19 18:24:27.0256008 +0000 UTC 76 | package mypkg 77 | 78 | func (c DoThing) AggregateID() string { return c.ID } 79 | func (c DoThing) AggregateType() string { return "thing" } 80 | func (c DoThing) CommandType() string { return "DoThing" } 81 | 82 | func (c DoAnother) AggregateID() string { return c.ID } 83 | func (c DoAnother) AggregateType() string { return "thing" } 84 | func (c DoAnother) CommandType() string { return "DoAnother" } 85 | ` 86 | assert.Equal(t, expectedOut, out.String()) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cqrs/cqrs.go: -------------------------------------------------------------------------------- 1 | package cqrs 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "log" 7 | 8 | "github.com/inklabs/rangedb" 9 | ) 10 | 11 | // Command defines a CQRS command. 12 | type Command interface { 13 | rangedb.AggregateMessage 14 | CommandType() string 15 | } 16 | 17 | // CommandDispatcher defines the interface for dispatching a cqrs.Command. 18 | type CommandDispatcher interface { 19 | Dispatch(Command) []rangedb.Event 20 | } 21 | 22 | // Aggregate defines the interface for a CQRS aggregate, or Unit of Certainty. 23 | type Aggregate interface { 24 | Load(rangedb.RecordIterator) 25 | Handle(Command) []rangedb.Event 26 | CommandTypes() []string 27 | } 28 | 29 | type cqrs struct { 30 | store rangedb.Store 31 | aggregates map[string]Aggregate 32 | logger *log.Logger 33 | } 34 | 35 | // Option defines functional option parameters for App. 36 | type Option func(*cqrs) 37 | 38 | // WithLogger is a functional option to inject a Logger. 39 | func WithLogger(logger *log.Logger) Option { 40 | return func(c *cqrs) { 41 | c.logger = logger 42 | } 43 | } 44 | 45 | // WithAggregates is a functional option to inject an aggregate command handler. 46 | func WithAggregates(aggregates ...Aggregate) Option { 47 | return func(c *cqrs) { 48 | for _, aggregate := range aggregates { 49 | for _, commandType := range aggregate.CommandTypes() { 50 | c.aggregates[commandType] = aggregate 51 | } 52 | } 53 | } 54 | } 55 | 56 | // New constructs an event sourced CQRS application 57 | func New(store rangedb.Store, options ...Option) *cqrs { 58 | cqrs := &cqrs{ 59 | store: store, 60 | logger: log.New(ioutil.Discard, "", 0), 61 | aggregates: make(map[string]Aggregate), 62 | } 63 | 64 | for _, option := range options { 65 | option(cqrs) 66 | } 67 | 68 | return cqrs 69 | } 70 | 71 | func (c *cqrs) Dispatch(command Command) []rangedb.Event { 72 | var preHandlerEvents []rangedb.Event 73 | 74 | commandHandler, ok := c.aggregates[command.CommandType()] 75 | if !ok { 76 | c.logger.Printf("command handler not found for: %s", command.CommandType()) 77 | return preHandlerEvents 78 | } 79 | 80 | ctx := context.Background() 81 | streamName := rangedb.GetEventStream(command) 82 | eventStream := c.store.EventsByStream(ctx, 0, streamName) 83 | commandHandler.Load(eventStream) 84 | handlerEvents := commandHandler.Handle(command) 85 | 86 | events := append(preHandlerEvents, handlerEvents...) 87 | return c.saveEvents(ctx, events) 88 | } 89 | 90 | func (c *cqrs) saveEvents(ctx context.Context, events []rangedb.Event) []rangedb.Event { 91 | var savedEvents []rangedb.Event 92 | for _, event := range events { 93 | streamName := rangedb.GetEventStream(event) 94 | _, err := c.store.Save(ctx, streamName, &rangedb.EventRecord{Event: event}) 95 | if err != nil { 96 | c.logger.Printf("unable to save event: %v", err) 97 | continue 98 | } 99 | 100 | savedEvents = append(savedEvents, event) 101 | } 102 | 103 | return savedEvents 104 | } 105 | -------------------------------------------------------------------------------- /pkg/crypto/aes/cbc_pkcs5_padding_test.go: -------------------------------------------------------------------------------- 1 | package aes_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/inklabs/rangedb/pkg/crypto/aes" 7 | "github.com/inklabs/rangedb/pkg/crypto/aes/aestest" 8 | ) 9 | 10 | func TestCBCPKCS5Padding(t *testing.T) { 11 | aestest.VerifyAESEncryption(t, aes.NewCBCPKCS5Padding()) 12 | } 13 | 14 | func BenchmarkCBCPKCS5Padding(b *testing.B) { 15 | const AESCBCPKCS5PaddingBase64CipherText = "hZUAwIGBqAzjDlMDIGvztI0du4Vedspv1IHD48iKfg4=" 16 | aestest.AESEncryptorBenchmark(b, aes.NewCBCPKCS5Padding(), AESCBCPKCS5PaddingBase64CipherText) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/crypto/aes/gcm.go: -------------------------------------------------------------------------------- 1 | package aes 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | cryptoRand "crypto/rand" 7 | "encoding/base64" 8 | "io" 9 | 10 | "github.com/inklabs/rangedb/pkg/crypto" 11 | ) 12 | 13 | type gcm struct { 14 | randReader io.Reader 15 | } 16 | 17 | // NewGCM constructs an AES/GCM encryption engine. 18 | func NewGCM() *gcm { 19 | return &gcm{ 20 | randReader: cryptoRand.Reader, 21 | } 22 | } 23 | 24 | // Encrypt returns AES/GCM base64 cipher text. 25 | // The key argument should be the base64 encoded AES key, 26 | // either 16, 24, or 32 bytes to select 27 | // AES-128, AES-192, or AES-256. 28 | func (e *gcm) Encrypt(base64Key, plainText string) (string, error) { 29 | key, err := base64.StdEncoding.DecodeString(base64Key) 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | cipherText, err := e.encrypt([]byte(plainText), key) 35 | base64CipherText := base64.StdEncoding.EncodeToString(cipherText) 36 | return base64CipherText, err 37 | } 38 | 39 | // Decrypt returns a decrypted string from AES/GCM base64 cipher text. 40 | func (e *gcm) Decrypt(base64Key, base64CipherText string) (string, error) { 41 | key, err := base64.StdEncoding.DecodeString(base64Key) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | cipherText, err := base64.StdEncoding.DecodeString(base64CipherText) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | decryptedData, err := e.decrypt(key, cipherText) 52 | return string(decryptedData), err 53 | } 54 | 55 | func (e *gcm) encrypt(plainText, key []byte) ([]byte, error) { 56 | cipherBlock, err := aes.NewCipher(key) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | gcm, err := cipher.NewGCM(cipherBlock) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | nonce := make([]byte, gcm.NonceSize()) 67 | if _, err := io.ReadFull(e.randReader, nonce); err != nil { 68 | return nil, err 69 | } 70 | 71 | sealedCipherText := gcm.Seal(nonce, nonce, plainText, nil) 72 | return sealedCipherText, nil 73 | } 74 | 75 | func (e *gcm) decrypt(key, sealedCipherText []byte) ([]byte, error) { 76 | if len(sealedCipherText) == 0 { 77 | return nil, crypto.ErrInvalidCipherText 78 | } 79 | 80 | cipherBlock, err := aes.NewCipher(key) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | gcm, err := cipher.NewGCM(cipherBlock) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | if len(sealedCipherText) < gcm.NonceSize() { 91 | return nil, crypto.ErrInvalidCipherText 92 | } 93 | 94 | nonceSize := gcm.NonceSize() 95 | nonce, cipherText := sealedCipherText[:nonceSize], sealedCipherText[nonceSize:] 96 | 97 | plainText, err := gcm.Open(nil, nonce, cipherText, nil) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return plainText, nil 103 | } 104 | 105 | func (e *gcm) SetRandReader(randReader io.Reader) { 106 | e.randReader = randReader 107 | } 108 | -------------------------------------------------------------------------------- /pkg/crypto/aes/gcm_test.go: -------------------------------------------------------------------------------- 1 | package aes_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/inklabs/rangedb/pkg/crypto/aes" 10 | "github.com/inklabs/rangedb/pkg/crypto/aes/aestest" 11 | ) 12 | 13 | func TestGCM(t *testing.T) { 14 | aestest.VerifyAESEncryption(t, aes.NewGCM()) 15 | } 16 | 17 | func TestGCM_Errors(t *testing.T) { 18 | t.Run("unable to read nonce during encrypt", func(t *testing.T) { 19 | // Given 20 | failingReader := failingReader{} 21 | encryptor := aes.NewGCM() 22 | encryptor.SetRandReader(failingReader) 23 | 24 | // When 25 | encryptedValue, err := encryptor.Encrypt(aestest.ValidAES256Base64Key, aestest.PlainText) 26 | 27 | // Then 28 | assert.EqualError(t, err, "failingReader.Read") 29 | assert.Equal(t, "", encryptedValue) 30 | }) 31 | 32 | t.Run("too short cipher text on decrypt", func(t *testing.T) { 33 | // Given 34 | const InvalidShortAESGCMBase64CipherText = "Glq32jvn9nPO/pqxN9p3YQT4pvoZuV4aQCOy/TIdEtqW8vtGMns=" 35 | failingReader := failingReader{} 36 | encryptor := aes.NewGCM() 37 | encryptor.SetRandReader(failingReader) 38 | 39 | // When 40 | encryptedValue, err := encryptor.Decrypt(aestest.ValidAES256Base64Key, InvalidShortAESGCMBase64CipherText) 41 | 42 | // Then 43 | assert.EqualError(t, err, "cipher: message authentication failed") 44 | assert.Equal(t, "", encryptedValue) 45 | }) 46 | } 47 | 48 | func BenchmarkGCM(b *testing.B) { 49 | aestest.AESEncryptorBenchmark(b, aes.NewGCM(), aestest.ValidAESGCMBase64CipherText) 50 | } 51 | 52 | type failingReader struct{} 53 | 54 | func (f failingReader) Read(_ []byte) (n int, err error) { 55 | return n, fmt.Errorf("failingReader.Read") 56 | } 57 | -------------------------------------------------------------------------------- /pkg/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | // Encryptor defines how to encrypt/decrypt string data using base64. 10 | type Encryptor interface { 11 | Encrypt(key, plainText string) (string, error) 12 | Decrypt(key, cipherText string) (string, error) 13 | } 14 | 15 | // EventEncryptor defines how to encrypt/decrypt a rangedb.Event 16 | type EventEncryptor interface { 17 | Encrypt(event rangedb.Event) error 18 | Decrypt(event rangedb.Event) error 19 | } 20 | 21 | // SelfEncryptor defines how events encrypt/decrypt themselves. 22 | type SelfEncryptor interface { 23 | Encrypt(encryptor Encryptor) error 24 | Decrypt(encryptor Encryptor) error 25 | } 26 | 27 | // KeyStore defines how encryption keys are stored. Verified by cryptotest.VerifyKeyStore. 28 | type KeyStore interface { 29 | Get(subjectID string) (string, error) 30 | Set(subjectID, key string) error 31 | Delete(subjectID string) error 32 | } 33 | 34 | // ErrKeyWasDeleted encryption key was removed error. 35 | var ErrKeyWasDeleted = fmt.Errorf("removed from GDPR request") 36 | 37 | // ErrKeyNotFound encryption key was not found error. 38 | var ErrKeyNotFound = fmt.Errorf("key not found") 39 | 40 | // ErrKeyExistsForSubjectID encryption key has already been set for subjectID. 41 | var ErrKeyExistsForSubjectID = fmt.Errorf("key already exists for subjectID") 42 | 43 | // ErrKeyAlreadyUsed encryption key has already been set for subjectID. 44 | var ErrKeyAlreadyUsed = fmt.Errorf("encryption key already used") 45 | 46 | // ErrInvalidKey encryption key is not valid. 47 | var ErrInvalidKey = fmt.Errorf("invalid encryption key") 48 | 49 | // ErrInvalidCipherText encryption cipher text is not valid. 50 | var ErrInvalidCipherText = fmt.Errorf("invalid cipher text") 51 | -------------------------------------------------------------------------------- /pkg/crypto/cryptotest/customer_events.go: -------------------------------------------------------------------------------- 1 | package cryptotest 2 | 3 | //go:generate go run ../../../gen/eventgenerator/main.go -id ID -aggregateType customer 4 | 5 | // CustomerSignedUp is an event used for testing PII encryption of string fields. 6 | type CustomerSignedUp struct { 7 | ID string `encrypt:"subject-id"` 8 | Name string `encrypt:"personal-data"` 9 | Email string `encrypt:"personal-data"` 10 | Status string 11 | } 12 | 13 | // CustomerAddedBirth is an event used for testing PII encryption of numeric fields. 14 | type CustomerAddedBirth struct { 15 | ID string `encrypt:"subject-id"` 16 | BirthMonth int `encrypt:"personal-data" serialized:"BirthMonthEncrypted"` 17 | BirthYear int `encrypt:"personal-data" serialized:"BirthYearEncrypted"` 18 | BirthMonthEncrypted string 19 | BirthYearEncrypted string 20 | } 21 | -------------------------------------------------------------------------------- /pkg/crypto/cryptotest/failing_event_encryptor.go: -------------------------------------------------------------------------------- 1 | package cryptotest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | type failingEventEncryptor struct{} 10 | 11 | // NewFailingEventEncryptor always errors on Encrypt/Decrypt 12 | func NewFailingEventEncryptor() *failingEventEncryptor { 13 | return &failingEventEncryptor{} 14 | } 15 | 16 | func (f *failingEventEncryptor) Encrypt(_ rangedb.Event) error { 17 | return fmt.Errorf("failingEventEncryptor:Encrypt") 18 | } 19 | 20 | func (f *failingEventEncryptor) Decrypt(_ rangedb.Event) error { 21 | return fmt.Errorf("failingEventEncryptor:Decrypt") 22 | } 23 | -------------------------------------------------------------------------------- /pkg/crypto/cryptotest/rot13_encryption.go: -------------------------------------------------------------------------------- 1 | package cryptotest 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type rotCipher struct{} 8 | 9 | func NewRot13Cipher() *rotCipher { 10 | return &rotCipher{} 11 | } 12 | 13 | func (r rotCipher) Encrypt(_, data string) (string, error) { 14 | return strings.Map(rot, data), nil 15 | } 16 | 17 | func (r rotCipher) Decrypt(_, cipherText string) (string, error) { 18 | return strings.Map(rot, cipherText), nil 19 | } 20 | 21 | func rot(r rune) rune { 22 | capital := r >= 'A' && r <= 'Z' 23 | if !capital && (r < 'a' || r > 'z') { 24 | return r 25 | } 26 | 27 | r += 13 28 | if capital && r > 'Z' || !capital && r > 'z' { 29 | r -= 26 30 | } 31 | return r 32 | } 33 | -------------------------------------------------------------------------------- /pkg/crypto/cryptotest/rot13_encryption_test.go: -------------------------------------------------------------------------------- 1 | package cryptotest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 10 | ) 11 | 12 | const unusedKey = "" 13 | 14 | func TestRot13Cipher(t *testing.T) { 15 | const ( 16 | text = "Too many secrets!" 17 | encryptedText = "Gbb znal frpergf!" 18 | ) 19 | 20 | t.Run("encrypts string", func(t *testing.T) { 21 | // Given 22 | rot13 := cryptotest.NewRot13Cipher() 23 | 24 | // When 25 | encryptedValue, err := rot13.Encrypt(unusedKey, text) 26 | 27 | // Then 28 | require.NoError(t, err) 29 | assert.Equal(t, encryptedText, encryptedValue) 30 | }) 31 | 32 | t.Run("decrypts string", func(t *testing.T) { 33 | // Given 34 | rot13 := cryptotest.NewRot13Cipher() 35 | 36 | // When 37 | decryptedValue, err := rot13.Decrypt(unusedKey, encryptedText) 38 | 39 | // Then 40 | require.NoError(t, err) 41 | assert.Equal(t, text, decryptedValue) 42 | }) 43 | } 44 | 45 | func BenchmarkRot13Cipher(b *testing.B) { 46 | const ( 47 | text = "lorem ipsum" 48 | encryptedText = "yberz vcfhz" 49 | ) 50 | cipher := cryptotest.NewRot13Cipher() 51 | 52 | b.Run("encrypt", func(b *testing.B) { 53 | for i := 0; i < b.N; i++ { 54 | _, _ = cipher.Encrypt(unusedKey, text) 55 | } 56 | }) 57 | 58 | b.Run("decrypt", func(b *testing.B) { 59 | for i := 0; i < b.N; i++ { 60 | _, _ = cipher.Decrypt(unusedKey, encryptedText) 61 | } 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/crypto/eventencryptor/delete_encryption_key_test.go: -------------------------------------------------------------------------------- 1 | package eventencryptor_test 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | 7 | "github.com/inklabs/rangedb/pkg/crypto/aes" 8 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 9 | "github.com/inklabs/rangedb/pkg/crypto/eventencryptor" 10 | "github.com/inklabs/rangedb/pkg/crypto/provider/inmemorykeystore" 11 | ) 12 | 13 | func ExampleKeyStore_Delete() { 14 | // Given 15 | seededRandReader := rand.New(rand.NewSource(100)) 16 | aesEncryptor := aes.NewGCM() 17 | aesEncryptor.SetRandReader(seededRandReader) 18 | keyStore := inmemorykeystore.New() 19 | eventEncryptor := eventencryptor.New(keyStore, aesEncryptor) 20 | eventEncryptor.SetRandReader(seededRandReader) 21 | event := &cryptotest.CustomerSignedUp{ 22 | ID: "62df778c16f84969a8a5448a9ce00218", 23 | Name: "John Doe", 24 | Email: "john@example.com", 25 | Status: "active", 26 | } 27 | 28 | // When 29 | PrintError(eventEncryptor.Encrypt(event)) 30 | PrintEvent(event) 31 | 32 | PrintError(keyStore.Delete(event.AggregateID())) 33 | 34 | err := eventEncryptor.Decrypt(event) 35 | fmt.Printf("error: %v\n", err) 36 | 37 | PrintEvent(event) 38 | 39 | // Output: 40 | // { 41 | // "ID": "62df778c16f84969a8a5448a9ce00218", 42 | // "Name": "Lp5pGK8QGYw3NJyJVBsW49HESSf+NEraAQoBmpLXboZvsN/L", 43 | // "Email": "o1H9t1BClYc5UcyUV+Roe3wz5gwRZRjgBI/xzwZs8ueQGQ5L8uGnbrTGrh8=", 44 | // "Status": "active" 45 | // } 46 | // error: removed from GDPR request 47 | // { 48 | // "ID": "62df778c16f84969a8a5448a9ce00218", 49 | // "Name": "", 50 | // "Email": "", 51 | // "Status": "active" 52 | // } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/crypto/eventencryptor/encrypt_event_test.go: -------------------------------------------------------------------------------- 1 | package eventencryptor_test 2 | 3 | import ( 4 | "math/rand" 5 | 6 | "github.com/inklabs/rangedb/pkg/crypto/aes" 7 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 8 | "github.com/inklabs/rangedb/pkg/crypto/eventencryptor" 9 | "github.com/inklabs/rangedb/pkg/crypto/provider/inmemorykeystore" 10 | ) 11 | 12 | func ExampleEventEncryptor_Encrypt() { 13 | // Given 14 | seededRandReader := rand.New(rand.NewSource(100)) 15 | aesEncryptor := aes.NewGCM() 16 | aesEncryptor.SetRandReader(seededRandReader) 17 | keyStore := inmemorykeystore.New() 18 | eventEncryptor := eventencryptor.New(keyStore, aesEncryptor) 19 | eventEncryptor.SetRandReader(seededRandReader) 20 | event := &cryptotest.CustomerSignedUp{ 21 | ID: "fe65ac8d8c3a45e9b3cee407f10ee518", 22 | Name: "John Doe", 23 | Email: "john@example.com", 24 | Status: "active", 25 | } 26 | 27 | // When 28 | PrintError(eventEncryptor.Encrypt(event)) 29 | PrintEvent(event) 30 | 31 | PrintError(eventEncryptor.Decrypt(event)) 32 | PrintEvent(event) 33 | 34 | // Output: 35 | // { 36 | // "ID": "fe65ac8d8c3a45e9b3cee407f10ee518", 37 | // "Name": "Lp5pGK8QGYw3NJyJVBsW49HESSf+NEraAQoBmpLXboZvsN/L", 38 | // "Email": "o1H9t1BClYc5UcyUV+Roe3wz5gwRZRjgBI/xzwZs8ueQGQ5L8uGnbrTGrh8=", 39 | // "Status": "active" 40 | // } 41 | // { 42 | // "ID": "fe65ac8d8c3a45e9b3cee407f10ee518", 43 | // "Name": "John Doe", 44 | // "Email": "john@example.com", 45 | // "Status": "active" 46 | // } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/crypto/eventencryptor/event_encryptor.go: -------------------------------------------------------------------------------- 1 | package eventencryptor 2 | 3 | import ( 4 | cryptoRand "crypto/rand" 5 | "encoding/base64" 6 | "io" 7 | 8 | "github.com/inklabs/rangedb" 9 | "github.com/inklabs/rangedb/pkg/crypto" 10 | ) 11 | 12 | type eventEncryptor struct { 13 | engine *engine 14 | } 15 | 16 | func New(store crypto.KeyStore, encryptor crypto.Encryptor) *eventEncryptor { 17 | return &eventEncryptor{ 18 | engine: newEngine(store, encryptor), 19 | } 20 | } 21 | 22 | func (e *eventEncryptor) Encrypt(event rangedb.Event) error { 23 | if event, ok := event.(crypto.SelfEncryptor); ok { 24 | return event.Encrypt(e.engine) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (e *eventEncryptor) Decrypt(event rangedb.Event) error { 31 | if selfEncryptingEvent, ok := event.(crypto.SelfEncryptor); ok { 32 | return selfEncryptingEvent.Decrypt(e.engine) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (e *eventEncryptor) SetRandReader(randReader io.Reader) { 39 | e.engine.setRandReader(randReader) 40 | } 41 | 42 | type engine struct { 43 | keyStore crypto.KeyStore 44 | encryptor crypto.Encryptor 45 | randReader io.Reader 46 | } 47 | 48 | func newEngine(store crypto.KeyStore, encryptor crypto.Encryptor) *engine { 49 | return &engine{ 50 | keyStore: store, 51 | encryptor: encryptor, 52 | randReader: cryptoRand.Reader, 53 | } 54 | } 55 | 56 | func (e *engine) Encrypt(subjectID, data string) (string, error) { 57 | base64Key, err := e.keyStore.Get(subjectID) 58 | if err == crypto.ErrKeyNotFound { 59 | base64Key, err = e.newBase64AES256Key() 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | err = e.keyStore.Set(subjectID, base64Key) 65 | } 66 | 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | return e.encryptor.Encrypt(base64Key, data) 72 | } 73 | 74 | func (e *engine) Decrypt(subjectID, cipherText string) (string, error) { 75 | base64Key, err := e.keyStore.Get(subjectID) 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | return e.encryptor.Decrypt(base64Key, cipherText) 81 | } 82 | 83 | func (e *engine) newBase64AES256Key() (string, error) { 84 | const aes256ByteLength = 32 85 | encryptionKey := make([]byte, aes256ByteLength) 86 | if _, err := io.ReadFull(e.randReader, encryptionKey); err != nil { 87 | return "", err 88 | } 89 | 90 | return base64.StdEncoding.EncodeToString(encryptionKey), nil 91 | } 92 | 93 | func (e *engine) setRandReader(randReader io.Reader) { 94 | e.randReader = randReader 95 | } 96 | -------------------------------------------------------------------------------- /pkg/crypto/eventencryptor/helper_test.go: -------------------------------------------------------------------------------- 1 | package eventencryptor_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/inklabs/rangedb" 8 | "github.com/inklabs/rangedb/pkg/jsontools" 9 | ) 10 | 11 | func PrintError(errors ...error) { 12 | for _, err := range errors { 13 | if err != nil { 14 | fmt.Println(err) 15 | } 16 | } 17 | } 18 | 19 | func PrintEvent(event rangedb.Event) { 20 | body, err := json.Marshal(event) 21 | PrintError(err) 22 | 23 | fmt.Println(jsontools.PrettyJSON(body)) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/crypto/provider/cachekeystore/cache_keystore.go: -------------------------------------------------------------------------------- 1 | package cachekeystore 2 | 3 | import ( 4 | "github.com/inklabs/rangedb/pkg/crypto" 5 | ) 6 | 7 | type cacheKeyStore struct { 8 | first crypto.KeyStore 9 | second crypto.KeyStore 10 | } 11 | 12 | func New(first crypto.KeyStore, second crypto.KeyStore) *cacheKeyStore { 13 | return &cacheKeyStore{ 14 | first: first, 15 | second: second, 16 | } 17 | } 18 | 19 | func (c *cacheKeyStore) Get(subjectID string) (string, error) { 20 | key, err := c.first.Get(subjectID) 21 | if err != nil { 22 | secondKey, secondErr := c.second.Get(subjectID) 23 | if secondErr != nil { 24 | return "", secondErr 25 | } 26 | 27 | return secondKey, c.first.Set(subjectID, secondKey) 28 | } 29 | 30 | return key, nil 31 | } 32 | 33 | func (c *cacheKeyStore) Set(subjectID, key string) error { 34 | err := c.second.Set(subjectID, key) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return c.first.Set(subjectID, key) 40 | } 41 | 42 | func (c *cacheKeyStore) Delete(subjectID string) error { 43 | err := c.second.Delete(subjectID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return c.first.Delete(subjectID) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/crypto/provider/dynamodbkeystore/README.md: -------------------------------------------------------------------------------- 1 | # DynamoDB KeyStore 2 | 3 | ## Testing 4 | 5 | ### Environment Variables 6 | 7 | ``` 8 | DYDB_AWS_REGION=us-east-1 9 | DYDB_TABLE_NAME=test_encryption_keys 10 | AWS_ACCESS_KEY_ID= 11 | AWS_SECRET_ACCESS_KEY= 12 | ``` 13 | 14 | ### Create Table 15 | 16 | ``` 17 | aws dynamodb create-table \ 18 | --table-name test_encryption_keys \ 19 | --attribute-definitions AttributeName=SubjectID,AttributeType=S \ 20 | --key-schema AttributeName=SubjectID,KeyType=HASH \ 21 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 22 | ``` 23 | 24 | ### List Tables 25 | 26 | ``` 27 | aws dynamodb list-tables 28 | ``` 29 | 30 | ### Scan Items 31 | 32 | ``` 33 | aws dynamodb scan --table-name test_encryption_keys 34 | ``` 35 | -------------------------------------------------------------------------------- /pkg/crypto/provider/dynamodbkeystore/config.go: -------------------------------------------------------------------------------- 1 | package dynamodbkeystore 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type Config struct { 9 | TableName string 10 | AWSRegion string 11 | AccessKeyID string 12 | SecretAccessKey string 13 | EndpointURL string 14 | } 15 | 16 | // NewConfigFromEnvironment loads a DynamoDB config from environment variables. 17 | func NewConfigFromEnvironment() (*Config, error) { 18 | awsRegion := os.Getenv("DYDB_AWS_REGION") 19 | tableName := os.Getenv("DYDB_TABLE_NAME") 20 | endpointURL := os.Getenv("DYDB_ENDPOINT_URL") 21 | accessKeyID := os.Getenv("AWS_ACCESS_KEY_ID") 22 | secretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 23 | 24 | if awsRegion == "" || tableName == "" || accessKeyID == "" || secretAccessKey == "" { 25 | return nil, fmt.Errorf("DynamoDB has not been configured via environment variables") 26 | } 27 | 28 | return &Config{ 29 | TableName: tableName, 30 | AWSRegion: awsRegion, 31 | AccessKeyID: accessKeyID, 32 | SecretAccessKey: secretAccessKey, 33 | EndpointURL: endpointURL, 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/crypto/provider/dynamodbkeystore/dynamodb_keystore_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbkeystore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/inklabs/rangedb/pkg/crypto" 9 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 10 | "github.com/inklabs/rangedb/pkg/crypto/provider/dynamodbkeystore" 11 | ) 12 | 13 | func TestDynamoDBKeyStore_VerifyKeyStoreInterface(t *testing.T) { 14 | config := configFromEnvironment(t) 15 | 16 | cryptotest.VerifyKeyStore(t, func(t *testing.T) crypto.KeyStore { 17 | keyStore, err := dynamodbkeystore.New(config) 18 | require.NoError(t, err) 19 | 20 | if config.EndpointURL != "" { 21 | _ = keyStore.CreateTable(config.TableName) 22 | } 23 | 24 | return keyStore 25 | }) 26 | } 27 | 28 | func configFromEnvironment(t *testing.T) *dynamodbkeystore.Config { 29 | config, err := dynamodbkeystore.NewConfigFromEnvironment() 30 | if err != nil { 31 | t.Skip("DynamoDB has not been configured via environment variables to run integration tests") 32 | } 33 | 34 | return config 35 | } 36 | -------------------------------------------------------------------------------- /pkg/crypto/provider/inmemorykeystore/inmemory_keystore.go: -------------------------------------------------------------------------------- 1 | package inmemorykeystore 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/inklabs/rangedb/pkg/crypto" 7 | ) 8 | 9 | type inMemoryKeyStore struct { 10 | mux sync.RWMutex 11 | EncryptionKeysBySubjectID map[string]string 12 | EncryptionKeys map[string]struct{} 13 | } 14 | 15 | func New() *inMemoryKeyStore { 16 | return &inMemoryKeyStore{ 17 | EncryptionKeysBySubjectID: make(map[string]string), 18 | EncryptionKeys: make(map[string]struct{}), 19 | } 20 | } 21 | 22 | func (i *inMemoryKeyStore) Get(subjectID string) (string, error) { 23 | i.mux.RLock() 24 | defer i.mux.RUnlock() 25 | 26 | if key, ok := i.EncryptionKeysBySubjectID[subjectID]; ok { 27 | if key == "" { 28 | return "", crypto.ErrKeyWasDeleted 29 | } 30 | 31 | return key, nil 32 | } 33 | 34 | return "", crypto.ErrKeyNotFound 35 | } 36 | 37 | func (i *inMemoryKeyStore) Set(subjectID, key string) error { 38 | if key == "" { 39 | return crypto.ErrInvalidKey 40 | } 41 | 42 | i.mux.Lock() 43 | defer i.mux.Unlock() 44 | 45 | if _, ok := i.EncryptionKeys[key]; ok { 46 | return crypto.ErrKeyAlreadyUsed 47 | } 48 | 49 | if _, ok := i.EncryptionKeysBySubjectID[subjectID]; ok { 50 | return crypto.ErrKeyExistsForSubjectID 51 | } 52 | 53 | i.EncryptionKeysBySubjectID[subjectID] = key 54 | i.EncryptionKeys[key] = struct{}{} 55 | 56 | return nil 57 | } 58 | 59 | func (i *inMemoryKeyStore) Delete(subjectID string) error { 60 | i.mux.Lock() 61 | defer i.mux.Unlock() 62 | 63 | i.EncryptionKeysBySubjectID[subjectID] = "" 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/crypto/provider/inmemorykeystore/inmemory_keystore_test.go: -------------------------------------------------------------------------------- 1 | package inmemorykeystore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/inklabs/rangedb/pkg/crypto" 7 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 8 | "github.com/inklabs/rangedb/pkg/crypto/provider/inmemorykeystore" 9 | ) 10 | 11 | func TestInMemoryCrypto_VerifyKeyStoreInterface(t *testing.T) { 12 | cryptotest.VerifyKeyStore(t, func(t *testing.T) crypto.KeyStore { 13 | return inmemorykeystore.New() 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/crypto/provider/postgreskeystore/postgres_keystore_test.go: -------------------------------------------------------------------------------- 1 | package postgreskeystore_test 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/inklabs/rangedb/pkg/crypto" 11 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 12 | "github.com/inklabs/rangedb/pkg/crypto/provider/postgreskeystore" 13 | "github.com/inklabs/rangedb/provider/postgresstore" 14 | ) 15 | 16 | func TestPostgresKeyStore_VerifyKeyStoreInterface(t *testing.T) { 17 | config := configFromEnvironment(t) 18 | 19 | cryptotest.VerifyKeyStore(t, func(t *testing.T) crypto.KeyStore { 20 | keyStore, err := postgreskeystore.New(config) 21 | require.NoError(t, err) 22 | 23 | t.Cleanup(func() { 24 | truncateRecords(t, config) 25 | }) 26 | 27 | return keyStore 28 | }) 29 | } 30 | 31 | func TestPostgresKeyStore_DeletesEncryptionKey(t *testing.T) { 32 | // Given 33 | const ( 34 | subjectID = "1b84dcd4487e4b09bf0142b563efe00e" 35 | encryptionKey = "42b88545b17445d386741ac471bd5842" 36 | ) 37 | config := configFromEnvironment(t) 38 | keyStore, err := postgreskeystore.New(config) 39 | require.NoError(t, err) 40 | require.NoError(t, keyStore.Set(subjectID, encryptionKey)) 41 | 42 | // When 43 | err = keyStore.Delete(subjectID) 44 | 45 | // Then 46 | require.NoError(t, err) 47 | actualEncryptionKey := encryptionKeyBySubjectID(t, config, subjectID) 48 | assert.NotEqual(t, encryptionKey, actualEncryptionKey) 49 | } 50 | 51 | func encryptionKeyBySubjectID(t *testing.T, config *postgresstore.Config, subjectID string) string { 52 | db, err := sql.Open("postgres", config.DataSourceName()) 53 | require.NoError(t, err) 54 | 55 | var encryptionKey string 56 | row := db.QueryRow("SELECT EncryptionKey FROM vault WHERE SubjectID = $1", subjectID) 57 | require.NoError(t, row.Scan(&encryptionKey)) 58 | 59 | return encryptionKey 60 | } 61 | 62 | func truncateRecords(t require.TestingT, config *postgresstore.Config) { 63 | db, err := sql.Open("postgres", config.DataSourceName()) 64 | require.NoError(t, err) 65 | 66 | statement := `TRUNCATE vault;` 67 | _, err = db.Exec(statement) 68 | require.NoError(t, err) 69 | 70 | require.NoError(t, db.Close()) 71 | } 72 | 73 | type testSkipper interface { 74 | Skip(args ...interface{}) 75 | } 76 | 77 | // TODO: Move postgresstore.Config to separate package 78 | func configFromEnvironment(t testSkipper) *postgresstore.Config { 79 | config, err := postgresstore.NewConfigFromEnvironment() 80 | if err != nil { 81 | t.Skip("Postgres DB has not been configured via environment variables to run integration tests") 82 | } 83 | 84 | return config 85 | } 86 | -------------------------------------------------------------------------------- /pkg/crypto/xchacha20poly1305/xchacha20poly1305.go: -------------------------------------------------------------------------------- 1 | package xchacha20poly1305 2 | 3 | import ( 4 | cryptoRand "crypto/rand" 5 | "encoding/base64" 6 | "io" 7 | 8 | "github.com/inklabs/rangedb/pkg/crypto" 9 | "golang.org/x/crypto/chacha20poly1305" 10 | ) 11 | 12 | type xChaCha20Poly1305 struct { 13 | randReader io.Reader 14 | } 15 | 16 | // New constructs a XChaCha20-Poly1305 encryption engine. 17 | func New() *xChaCha20Poly1305 { 18 | return &xChaCha20Poly1305{ 19 | randReader: cryptoRand.Reader, 20 | } 21 | } 22 | 23 | // Encrypt returns XChaCha20-Poly1305 base64 cipher text. 24 | // The key argument should be the base64 encoded 256-bit key. 25 | func (x *xChaCha20Poly1305) Encrypt(base64Key, plainText string) (string, error) { 26 | key, err := base64.StdEncoding.DecodeString(base64Key) 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | cipherText, err := x.encrypt([]byte(plainText), key) 32 | base64CipherText := base64.StdEncoding.EncodeToString(cipherText) 33 | return base64CipherText, err 34 | } 35 | 36 | // Decrypt returns a decrypted string from XChaCha20-Poly1305 base64 cipher text. 37 | func (x *xChaCha20Poly1305) Decrypt(base64Key, base64CipherText string) (string, error) { 38 | key, err := base64.StdEncoding.DecodeString(base64Key) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | cipherText, err := base64.StdEncoding.DecodeString(base64CipherText) 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | decryptedData, err := x.decrypt(key, cipherText) 49 | return string(decryptedData), err 50 | } 51 | 52 | func (x *xChaCha20Poly1305) encrypt(plainText, key []byte) ([]byte, error) { 53 | aead, err := chacha20poly1305.NewX(key) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | nonce := make([]byte, aead.NonceSize()) 59 | if _, err := io.ReadFull(x.randReader, nonce); err != nil { 60 | return nil, err 61 | } 62 | 63 | sealedCipherText := aead.Seal(nonce, nonce, plainText, nil) 64 | return sealedCipherText, nil 65 | } 66 | 67 | func (x *xChaCha20Poly1305) decrypt(key, sealedCipherText []byte) ([]byte, error) { 68 | if len(sealedCipherText) == 0 { 69 | return nil, crypto.ErrInvalidCipherText 70 | } 71 | 72 | aead, err := chacha20poly1305.NewX(key) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | nonceSize := aead.NonceSize() 78 | nonce, cipherText := sealedCipherText[:nonceSize], sealedCipherText[nonceSize:] 79 | 80 | plaintext, err := aead.Open(nil, nonce, cipherText, nil) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return plaintext, nil 86 | } 87 | 88 | func (x *xChaCha20Poly1305) SetRandReader(randReader io.Reader) { 89 | x.randReader = randReader 90 | } 91 | -------------------------------------------------------------------------------- /pkg/grpc/README.md: -------------------------------------------------------------------------------- 1 | # gRPC Build Instructions 2 | 3 | ## Install Protobuf 4 | 5 | ```bash 6 | brew install protobuf 7 | ``` 8 | 9 | ## Build rangedb.proto 10 | 11 | ```bash 12 | ./build-proto.sh 13 | ``` 14 | -------------------------------------------------------------------------------- /pkg/grpc/build-proto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go install google.golang.org/protobuf/cmd/protoc-gen-go \ 4 | google.golang.org/grpc/cmd/protoc-gen-go-grpc 5 | protoc -I=. --go_out=./rangedbpb --go-grpc_out=./rangedbpb ./rangedb.proto 6 | -------------------------------------------------------------------------------- /pkg/grpc/rangedb.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package rangedbpb; 4 | 5 | option go_package = ".;rangedbpb"; 6 | 7 | service RangeDB { 8 | rpc Events(EventsRequest) returns (stream Record) {} 9 | rpc EventsByAggregateType(EventsByAggregateTypeRequest) returns (stream Record) {} 10 | rpc EventsByStream(EventsByStreamRequest) returns (stream Record) {} 11 | rpc OptimisticDeleteStream(OptimisticDeleteStreamRequest) returns (OptimisticDeleteStreamResponse) {} 12 | rpc OptimisticSave(OptimisticSaveRequest) returns (SaveResponse) {} 13 | rpc Save(SaveRequest) returns (SaveResponse) {} 14 | rpc SubscribeToLiveEvents(SubscribeToLiveEventsRequest) returns (stream Record) {} 15 | rpc SubscribeToEvents(SubscribeToEventsRequest) returns (stream Record) {} 16 | rpc SubscribeToEventsByAggregateType(SubscribeToEventsByAggregateTypeRequest) returns (stream Record) {} 17 | rpc TotalEventsInStream(TotalEventsInStreamRequest) returns (TotalEventsInStreamResponse) {} 18 | } 19 | 20 | message EventsRequest { 21 | uint64 globalSequenceNumber = 1; 22 | } 23 | 24 | message EventsByStreamRequest { 25 | string streamName = 1; 26 | uint64 streamSequenceNumber = 2; 27 | } 28 | 29 | message EventsByAggregateTypeRequest { 30 | repeated string aggregateTypes = 1; 31 | uint64 globalSequenceNumber = 2; 32 | } 33 | 34 | message SubscribeToLiveEventsRequest {} 35 | 36 | message SubscribeToEventsRequest { 37 | uint64 globalSequenceNumber = 1; 38 | } 39 | 40 | message SubscribeToEventsByAggregateTypeRequest { 41 | repeated string aggregateTypes = 1; 42 | uint64 globalSequenceNumber = 2; 43 | } 44 | 45 | message OptimisticDeleteStreamRequest { 46 | uint64 ExpectedStreamSequenceNumber = 1; 47 | string StreamName = 2; 48 | } 49 | 50 | message OptimisticDeleteStreamResponse { 51 | uint32 EventsDeleted = 1; 52 | } 53 | 54 | message OptimisticSaveRequest { 55 | uint64 ExpectedStreamSequenceNumber = 1; 56 | string StreamName = 2; 57 | repeated Event Events = 3; 58 | } 59 | 60 | message SaveRequest { 61 | string StreamName = 1; 62 | repeated Event Events = 2; 63 | } 64 | 65 | message SaveResponse { 66 | uint32 EventsSaved = 1; 67 | uint64 LastStreamSequenceNumber = 2; 68 | } 69 | 70 | message SaveFailureResponse { 71 | string Message = 1; 72 | } 73 | 74 | message TotalEventsInStreamRequest { 75 | string StreamName = 1; 76 | } 77 | 78 | message TotalEventsInStreamResponse { 79 | uint64 TotalEvents = 1; 80 | } 81 | 82 | message Event { 83 | string AggregateType = 1; 84 | string AggregateID = 2; 85 | string EventType = 3; 86 | string Data = 4; 87 | string Metadata = 5; 88 | } 89 | 90 | message Record { 91 | string StreamName = 1; 92 | string AggregateType = 2; 93 | string AggregateID = 3; 94 | uint64 GlobalSequenceNumber = 4; 95 | uint64 StreamSequenceNumber = 5; 96 | uint64 InsertTimestamp = 6; 97 | string EventID = 7; 98 | string EventType = 8; 99 | string Data = 9; 100 | string Metadata = 10; 101 | } 102 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbpb/translate.go: -------------------------------------------------------------------------------- 1 | package rangedbpb 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/inklabs/rangedb" 9 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 10 | ) 11 | 12 | // ToPbRecord translates a rangedb.Record into a rangedbpb.Record. 13 | func ToPbRecord(record *rangedb.Record) (*Record, error) { 14 | data, err := json.Marshal(record.Data) 15 | if err != nil { 16 | return nil, fmt.Errorf("unable to marshal data: %v", err) 17 | } 18 | 19 | metadata, err := json.Marshal(record.Metadata) 20 | if err != nil { 21 | return nil, fmt.Errorf("unable to marshal metadata: %v", err) 22 | } 23 | 24 | return &Record{ 25 | StreamName: record.StreamName, 26 | AggregateType: record.AggregateType, 27 | AggregateID: record.AggregateID, 28 | GlobalSequenceNumber: record.GlobalSequenceNumber, 29 | StreamSequenceNumber: record.StreamSequenceNumber, 30 | InsertTimestamp: record.InsertTimestamp, 31 | EventID: record.EventID, 32 | EventType: record.EventType, 33 | Data: string(data), 34 | Metadata: string(metadata), 35 | }, nil 36 | } 37 | 38 | // ToRecord translates a rangedbpb.Record into a rangedb.Record. 39 | func ToRecord(pbRecord *Record, eventTypeIdentifier rangedb.EventTypeIdentifier) (*rangedb.Record, error) { 40 | data, err := jsonrecordserializer.DecodeJsonData( 41 | pbRecord.EventType, 42 | strings.NewReader(pbRecord.Data), 43 | eventTypeIdentifier, 44 | ) 45 | if err != nil { 46 | return nil, fmt.Errorf("unable to decode data: %v", err) 47 | } 48 | 49 | var metadata interface{} 50 | if pbRecord.Metadata != "null" { 51 | err = json.Unmarshal([]byte(pbRecord.Metadata), &metadata) 52 | if err != nil { 53 | return nil, fmt.Errorf("unable to unmarshal metadata: %v", err) 54 | } 55 | } 56 | 57 | return &rangedb.Record{ 58 | StreamName: pbRecord.StreamName, 59 | AggregateType: pbRecord.AggregateType, 60 | AggregateID: pbRecord.AggregateID, 61 | GlobalSequenceNumber: pbRecord.GlobalSequenceNumber, 62 | StreamSequenceNumber: pbRecord.StreamSequenceNumber, 63 | InsertTimestamp: pbRecord.InsertTimestamp, 64 | EventID: pbRecord.EventID, 65 | EventType: pbRecord.EventType, 66 | Data: data, 67 | Metadata: metadata, 68 | }, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbserver/helper_test.go: -------------------------------------------------------------------------------- 1 | package rangedbserver_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | func Close(c io.Closer) { 9 | err := c.Close() 10 | if err != nil { 11 | fmt.Printf("failed closing: %v", err) 12 | } 13 | } 14 | 15 | func PrintError(errors ...error) { 16 | for _, err := range errors { 17 | if err != nil { 18 | fmt.Println(err) 19 | } 20 | } 21 | } 22 | 23 | func IgnoreFirstNumber(_ uint64, err error) error { 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbserver/optimistic_delete_stream_failure_test.go: -------------------------------------------------------------------------------- 1 | package rangedbserver_test 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "time" 7 | 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/test/bufconn" 10 | 11 | "github.com/inklabs/rangedb/pkg/grpc/rangedbserver" 12 | 13 | "github.com/inklabs/rangedb" 14 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 15 | "github.com/inklabs/rangedb/pkg/grpc/rangedbpb" 16 | "github.com/inklabs/rangedb/provider/inmemorystore" 17 | "github.com/inklabs/rangedb/rangedbtest" 18 | ) 19 | 20 | func ExampleRangeDBServer_OptimisticDeleteStream_failure() { 21 | // Given 22 | inMemoryStore := inmemorystore.New( 23 | inmemorystore.WithClock(sequentialclock.New()), 24 | ) 25 | rangedbtest.BindEvents(inMemoryStore) 26 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 27 | defer cancel() 28 | const aggregateID = "c129a8fa3d8945928c3e300beb0b6d58" 29 | event1 := rangedbtest.ThingWasDone{ID: aggregateID, Number: 100} 30 | event2 := rangedbtest.ThingWasDone{ID: aggregateID, Number: 200} 31 | streamName := "thing-c129a8fa3d8945928c3e300beb0b6d58" 32 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamName, 33 | &rangedb.EventRecord{Event: event1}, 34 | &rangedb.EventRecord{Event: event2}, 35 | ))) 36 | 37 | // Setup gRPC server 38 | bufListener := bufconn.Listen(7) 39 | server := grpc.NewServer() 40 | defer server.Stop() 41 | rangeDBServer, err := rangedbserver.New(rangedbserver.WithStore(inMemoryStore)) 42 | PrintError(err) 43 | defer rangeDBServer.Stop() 44 | rangedbpb.RegisterRangeDBServer(server, rangeDBServer) 45 | go func() { 46 | PrintError(server.Serve(bufListener)) 47 | }() 48 | 49 | // Setup gRPC connection 50 | dialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { 51 | return bufListener.Dial() 52 | }) 53 | conn, err := grpc.DialContext(ctx, "bufnet", dialer, grpc.WithInsecure(), grpc.WithBlock()) 54 | defer Close(conn) 55 | PrintError(err) 56 | 57 | // Setup gRPC client 58 | rangeDBClient := rangedbpb.NewRangeDBClient(conn) 59 | optimisticDeleteStream := &rangedbpb.OptimisticDeleteStreamRequest{ 60 | ExpectedStreamSequenceNumber: 5, 61 | StreamName: streamName, 62 | } 63 | 64 | // When 65 | _, err = rangeDBClient.OptimisticDeleteStream(ctx, optimisticDeleteStream) 66 | PrintError(err) 67 | 68 | // Output: 69 | // rpc error: code = Unknown desc = unexpected sequence number: 5, actual: 2 70 | } 71 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbserver/optimistic_delete_stream_test.go: -------------------------------------------------------------------------------- 1 | package rangedbserver_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "time" 8 | 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/test/bufconn" 11 | 12 | "github.com/inklabs/rangedb/pkg/grpc/rangedbserver" 13 | 14 | "github.com/inklabs/rangedb" 15 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 16 | "github.com/inklabs/rangedb/pkg/grpc/rangedbpb" 17 | "github.com/inklabs/rangedb/provider/inmemorystore" 18 | "github.com/inklabs/rangedb/rangedbtest" 19 | ) 20 | 21 | func ExampleRangeDBServer_OptimisticDeleteStream() { 22 | // Given 23 | inMemoryStore := inmemorystore.New( 24 | inmemorystore.WithClock(sequentialclock.New()), 25 | ) 26 | rangedbtest.BindEvents(inMemoryStore) 27 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 28 | defer cancel() 29 | const aggregateID = "605f20348fb940e386c171d51c877bf1" 30 | event1 := rangedbtest.ThingWasDone{ID: aggregateID, Number: 100} 31 | event2 := rangedbtest.ThingWasDone{ID: aggregateID, Number: 200} 32 | streamName := "thing-605f20348fb940e386c171d51c877bf1" 33 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamName, 34 | &rangedb.EventRecord{Event: event1}, 35 | &rangedb.EventRecord{Event: event2}, 36 | ))) 37 | 38 | // Setup gRPC server 39 | bufListener := bufconn.Listen(7) 40 | server := grpc.NewServer() 41 | defer server.Stop() 42 | rangeDBServer, err := rangedbserver.New(rangedbserver.WithStore(inMemoryStore)) 43 | PrintError(err) 44 | defer rangeDBServer.Stop() 45 | rangedbpb.RegisterRangeDBServer(server, rangeDBServer) 46 | go func() { 47 | PrintError(server.Serve(bufListener)) 48 | }() 49 | 50 | // Setup gRPC connection 51 | dialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { 52 | return bufListener.Dial() 53 | }) 54 | conn, err := grpc.DialContext(ctx, "bufnet", dialer, grpc.WithInsecure(), grpc.WithBlock()) 55 | defer Close(conn) 56 | PrintError(err) 57 | 58 | // Setup gRPC client 59 | rangeDBClient := rangedbpb.NewRangeDBClient(conn) 60 | optimisticDeleteStream := &rangedbpb.OptimisticDeleteStreamRequest{ 61 | ExpectedStreamSequenceNumber: 2, 62 | StreamName: streamName, 63 | } 64 | 65 | // When 66 | response, err := rangeDBClient.OptimisticDeleteStream(ctx, optimisticDeleteStream) 67 | PrintError(err) 68 | fmt.Printf("Events Deleted: %d", response.EventsDeleted) 69 | 70 | // Output: 71 | // Events Deleted: 2 72 | } 73 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbserver/optimistic_save_test.go: -------------------------------------------------------------------------------- 1 | package rangedbserver_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "time" 9 | 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/test/bufconn" 12 | 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/grpc/rangedbpb" 15 | "github.com/inklabs/rangedb/pkg/grpc/rangedbserver" 16 | "github.com/inklabs/rangedb/pkg/jsontools" 17 | "github.com/inklabs/rangedb/provider/inmemorystore" 18 | "github.com/inklabs/rangedb/rangedbtest" 19 | ) 20 | 21 | func ExampleRangeDBServer_OptimisticSave() { 22 | // Given 23 | inMemoryStore := inmemorystore.New( 24 | inmemorystore.WithClock(sequentialclock.New()), 25 | ) 26 | rangedbtest.BindEvents(inMemoryStore) 27 | 28 | // Setup gRPC server 29 | bufListener := bufconn.Listen(7) 30 | server := grpc.NewServer() 31 | defer server.Stop() 32 | rangeDBServer, err := rangedbserver.New(rangedbserver.WithStore(inMemoryStore)) 33 | PrintError(err) 34 | defer rangeDBServer.Stop() 35 | rangedbpb.RegisterRangeDBServer(server, rangeDBServer) 36 | go func() { 37 | PrintError(server.Serve(bufListener)) 38 | }() 39 | 40 | // Setup gRPC connection 41 | dialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { 42 | return bufListener.Dial() 43 | }) 44 | connCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 45 | defer cancel() 46 | conn, err := grpc.DialContext(connCtx, "bufnet", dialer, grpc.WithInsecure(), grpc.WithBlock()) 47 | PrintError(err) 48 | defer Close(conn) 49 | 50 | // Setup gRPC client 51 | rangeDBClient := rangedbpb.NewRangeDBClient(conn) 52 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 53 | defer done() 54 | request := &rangedbpb.OptimisticSaveRequest{ 55 | ExpectedStreamSequenceNumber: 0, 56 | StreamName: "thing-141b39d2b9854f8093ef79dc47dae6af", 57 | Events: []*rangedbpb.Event{ 58 | { 59 | AggregateType: "thing", 60 | AggregateID: "141b39d2b9854f8093ef79dc47dae6af", 61 | EventType: "ThingWasDone", 62 | Data: `{"id":"141b39d2b9854f8093ef79dc47dae6af","number":100}`, 63 | Metadata: "", 64 | }, 65 | { 66 | AggregateType: "thing", 67 | AggregateID: "141b39d2b9854f8093ef79dc47dae6af", 68 | EventType: "ThingWasDone", 69 | Data: `{"id":"141b39d2b9854f8093ef79dc47dae6af","number":200}`, 70 | Metadata: "", 71 | }, 72 | }, 73 | } 74 | 75 | // When 76 | response, err := rangeDBClient.OptimisticSave(ctx, request) 77 | PrintError(err) 78 | 79 | body, err := json.Marshal(response) 80 | PrintError(err) 81 | 82 | fmt.Println(jsontools.PrettyJSON(body)) 83 | 84 | // Output: 85 | // { 86 | // "EventsSaved": 2, 87 | // "LastStreamSequenceNumber": 2 88 | // } 89 | } 90 | -------------------------------------------------------------------------------- /pkg/grpc/rangedbserver/save_test.go: -------------------------------------------------------------------------------- 1 | package rangedbserver_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "time" 9 | 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/test/bufconn" 12 | 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/grpc/rangedbpb" 15 | "github.com/inklabs/rangedb/pkg/grpc/rangedbserver" 16 | "github.com/inklabs/rangedb/pkg/jsontools" 17 | "github.com/inklabs/rangedb/provider/inmemorystore" 18 | ) 19 | 20 | func ExampleRangeDBServer_Save() { 21 | // Given 22 | inMemoryStore := inmemorystore.New( 23 | inmemorystore.WithClock(sequentialclock.New()), 24 | ) 25 | 26 | // Setup gRPC server 27 | bufListener := bufconn.Listen(7) 28 | server := grpc.NewServer() 29 | defer server.Stop() 30 | rangeDBServer, err := rangedbserver.New(rangedbserver.WithStore(inMemoryStore)) 31 | PrintError(err) 32 | defer rangeDBServer.Stop() 33 | rangedbpb.RegisterRangeDBServer(server, rangeDBServer) 34 | go func() { 35 | PrintError(server.Serve(bufListener)) 36 | }() 37 | 38 | // Setup gRPC connection 39 | dialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { 40 | return bufListener.Dial() 41 | }) 42 | connCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 43 | defer cancel() 44 | conn, err := grpc.DialContext(connCtx, "bufnet", dialer, grpc.WithInsecure(), grpc.WithBlock()) 45 | PrintError(err) 46 | defer Close(conn) 47 | 48 | // Setup gRPC client 49 | rangeDBClient := rangedbpb.NewRangeDBClient(conn) 50 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 51 | defer done() 52 | request := &rangedbpb.SaveRequest{ 53 | StreamName: "thing-141b39d2b9854f8093ef79dc47dae6af", 54 | Events: []*rangedbpb.Event{ 55 | { 56 | AggregateType: "thing", 57 | AggregateID: "141b39d2b9854f8093ef79dc47dae6af", 58 | EventType: "ThingWasDone", 59 | Data: `{"id":"141b39d2b9854f8093ef79dc47dae6af","number":100}`, 60 | Metadata: "", 61 | }, 62 | { 63 | AggregateType: "thing", 64 | AggregateID: "141b39d2b9854f8093ef79dc47dae6af", 65 | EventType: "ThingWasDone", 66 | Data: `{"id":"141b39d2b9854f8093ef79dc47dae6af","number":200}`, 67 | Metadata: "", 68 | }, 69 | }, 70 | } 71 | 72 | // When 73 | response, err := rangeDBClient.Save(ctx, request) 74 | PrintError(err) 75 | 76 | body, err := json.Marshal(response) 77 | PrintError(err) 78 | 79 | fmt.Println(jsontools.PrettyJSON(body)) 80 | 81 | // Output: 82 | // { 83 | // "EventsSaved": 2, 84 | // "LastStreamSequenceNumber": 2 85 | // } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/jsontools/json_tools.go: -------------------------------------------------------------------------------- 1 | package jsontools 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "strings" 7 | ) 8 | 9 | // PrettyJSONString returns a human readable JSON formatted string. 10 | func PrettyJSONString(input string) string { 11 | return PrettyJSON([]byte(input)) 12 | } 13 | 14 | // PrettyJSON returns a human readable JSON formatted string. 15 | func PrettyJSON(input []byte) string { 16 | var prettyJSON bytes.Buffer 17 | _ = json.Indent(&prettyJSON, input, "", " ") 18 | return strings.TrimSpace(prettyJSON.String()) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/projection/disk_snapshot_store.go: -------------------------------------------------------------------------------- 1 | package projection 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type diskSnapshotStore struct { 9 | basePath string 10 | } 11 | 12 | // NewDiskSnapshotStore constructs a snapshot store that persists to disk. 13 | func NewDiskSnapshotStore(basePath string) *diskSnapshotStore { 14 | return &diskSnapshotStore{ 15 | basePath: basePath, 16 | } 17 | } 18 | 19 | // Load reads the projection state from a file. 20 | func (d *diskSnapshotStore) Load(projection SnapshotProjection) error { 21 | file, err := os.Open(d.SnapshotPath(projection)) 22 | if err != nil { 23 | return fmt.Errorf("unable to open snapshot file: %v", err) 24 | } 25 | defer file.Close() 26 | 27 | err = projection.LoadFromSnapshot(file) 28 | if err != nil { 29 | return fmt.Errorf("unable to load snapshot: %v", err) 30 | } 31 | 32 | return nil 33 | } 34 | 35 | // Save persists the projection to a file. 36 | func (d *diskSnapshotStore) Save(projection SnapshotProjection) error { 37 | file, err := os.Create(d.SnapshotPath(projection)) 38 | if err != nil { 39 | return fmt.Errorf("unable to create/open snapshot file: %v", err) 40 | } 41 | defer file.Close() 42 | 43 | err = projection.SaveSnapshot(file) 44 | if err != nil { 45 | return fmt.Errorf("unable to save snapshot: %v", err) 46 | } 47 | 48 | _ = file.Sync() 49 | 50 | return nil 51 | } 52 | 53 | // SnapshotPath returns the file path for the snapshot. 54 | func (d *diskSnapshotStore) SnapshotPath(projection SnapshotProjection) string { 55 | return fmt.Sprintf("%s/%s.snap", d.basePath, projection.SnapshotName()) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/projection/snapshot_store.go: -------------------------------------------------------------------------------- 1 | package projection 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // SnapshotStore defines the interface for loading/saving a SnapshotProjection. 8 | type SnapshotStore interface { 9 | Load(projection SnapshotProjection) error 10 | Save(projection SnapshotProjection) error 11 | } 12 | 13 | // SnapshotProjection defines the interface for loading/saving a projection snapshot. 14 | type SnapshotProjection interface { 15 | SnapshotName() string 16 | SaveSnapshot(w io.Writer) error 17 | LoadFromSnapshot(r io.Reader) error 18 | } 19 | -------------------------------------------------------------------------------- /pkg/rangedbapi/api_private_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_InvalidInput(t *testing.T) { 11 | // Given 12 | err := fmt.Errorf("EOF") 13 | 14 | // When 15 | invalidErr := newInvalidInput(err) 16 | 17 | // Then 18 | assert.Equal(t, "invalid input: EOF", invalidErr.Error()) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/rangedbapi/delete_stream_with_optimistic_concurrency_failure_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/jsontools" 15 | "github.com/inklabs/rangedb/pkg/rangedbapi" 16 | "github.com/inklabs/rangedb/provider/inmemorystore" 17 | "github.com/inklabs/rangedb/rangedbtest" 18 | ) 19 | 20 | func Example_optimisticDeleteStream_failure() { 21 | // Given 22 | inMemoryStore := inmemorystore.New( 23 | inmemorystore.WithClock(sequentialclock.New()), 24 | ) 25 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 26 | PrintError(err) 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 29 | defer cancel() 30 | 31 | streamName := "thing-4b9a415c53734b69ac459a7e53eb4c1b" 32 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamName, 33 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "4b9a415c53734b69ac459a7e53eb4c1b", Number: 100}}, 34 | ))) 35 | 36 | server := httptest.NewServer(api) 37 | defer server.Close() 38 | 39 | serverURL, err := url.Parse(server.URL) 40 | PrintError(err) 41 | serverURL.Path = "/delete-stream/thing-4b9a415c53734b69ac459a7e53eb4c1b" 42 | 43 | request, err := http.NewRequest(http.MethodPost, serverURL.String(), nil) 44 | PrintError(err) 45 | request.Header.Set("ExpectedStreamSequenceNumber", "2") 46 | client := http.DefaultClient 47 | 48 | // When 49 | response, err := client.Do(request) 50 | PrintError(err) 51 | defer Close(response.Body) 52 | 53 | body, err := ioutil.ReadAll(response.Body) 54 | PrintError(err) 55 | fmt.Println(response.Status) 56 | fmt.Println(jsontools.PrettyJSON(body)) 57 | 58 | // Output: 59 | // 409 Conflict 60 | // { 61 | // "status": "Failed", 62 | // "message": "unexpected sequence number: 2, actual: 1" 63 | // } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/rangedbapi/delete_stream_with_optimistic_concurrency_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/jsontools" 15 | "github.com/inklabs/rangedb/pkg/rangedbapi" 16 | "github.com/inklabs/rangedb/provider/inmemorystore" 17 | "github.com/inklabs/rangedb/rangedbtest" 18 | ) 19 | 20 | func Example_optimisticDeleteStream() { 21 | // Given 22 | inMemoryStore := inmemorystore.New( 23 | inmemorystore.WithClock(sequentialclock.New()), 24 | ) 25 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 26 | PrintError(err) 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 29 | defer cancel() 30 | 31 | streamName := "thing-4b9a415c53734b69ac459a7e53eb4c1b" 32 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamName, 33 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "4b9a415c53734b69ac459a7e53eb4c1b", Number: 100}}, 34 | ))) 35 | 36 | server := httptest.NewServer(api) 37 | defer server.Close() 38 | 39 | serverURL, err := url.Parse(server.URL) 40 | PrintError(err) 41 | serverURL.Path = "/delete-stream/thing-4b9a415c53734b69ac459a7e53eb4c1b" 42 | 43 | request, err := http.NewRequest(http.MethodPost, serverURL.String(), nil) 44 | PrintError(err) 45 | request.Header.Set("ExpectedStreamSequenceNumber", "1") 46 | client := http.DefaultClient 47 | 48 | // When 49 | response, err := client.Do(request) 50 | PrintError(err) 51 | defer Close(response.Body) 52 | 53 | body, err := ioutil.ReadAll(response.Body) 54 | PrintError(err) 55 | fmt.Println(jsontools.PrettyJSON(body)) 56 | 57 | // Output: 58 | // { 59 | // "status": "OK", 60 | // "eventsDeleted": 1 61 | // } 62 | } 63 | -------------------------------------------------------------------------------- /pkg/rangedbapi/get_all_events_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/jsontools" 15 | "github.com/inklabs/rangedb/pkg/rangedbapi" 16 | "github.com/inklabs/rangedb/provider/inmemorystore" 17 | "github.com/inklabs/rangedb/rangedbtest" 18 | ) 19 | 20 | func Example_getAllEvents() { 21 | // Given 22 | rangedbtest.SetRand(100) 23 | inMemoryStore := inmemorystore.New( 24 | inmemorystore.WithClock(sequentialclock.New()), 25 | inmemorystore.WithUUIDGenerator(rangedbtest.NewSeededUUIDGenerator()), 26 | ) 27 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 28 | PrintError(err) 29 | 30 | server := httptest.NewServer(api) 31 | defer server.Close() 32 | 33 | serverURL, err := url.Parse(server.URL) 34 | PrintError(err) 35 | serverURL.Path = "/all-events.json" 36 | 37 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 38 | defer cancel() 39 | 40 | streamNameA := "thing-605f20348fb940e386c171d51c877bf1" 41 | streamNameB := "another-a095086e52bc4617a1763a62398cd645" 42 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameA, 43 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "605f20348fb940e386c171d51c877bf1", Number: 100}}, 44 | ))) 45 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameB, 46 | &rangedb.EventRecord{Event: rangedbtest.AnotherWasComplete{ID: "a095086e52bc4617a1763a62398cd645"}}, 47 | ))) 48 | 49 | // When 50 | response, err := http.Get(serverURL.String()) 51 | PrintError(err) 52 | defer Close(response.Body) 53 | 54 | body, err := ioutil.ReadAll(response.Body) 55 | PrintError(err) 56 | 57 | fmt.Println(jsontools.PrettyJSON(body)) 58 | 59 | // Output: 60 | // [ 61 | // { 62 | // "streamName": "thing-605f20348fb940e386c171d51c877bf1", 63 | // "aggregateType": "thing", 64 | // "aggregateID": "605f20348fb940e386c171d51c877bf1", 65 | // "globalSequenceNumber": 1, 66 | // "streamSequenceNumber": 1, 67 | // "insertTimestamp": 0, 68 | // "eventID": "d2ba8e70072943388203c438d4e94bf3", 69 | // "eventType": "ThingWasDone", 70 | // "data": { 71 | // "id": "605f20348fb940e386c171d51c877bf1", 72 | // "number": 100 73 | // }, 74 | // "metadata": null 75 | // }, 76 | // { 77 | // "streamName": "another-a095086e52bc4617a1763a62398cd645", 78 | // "aggregateType": "another", 79 | // "aggregateID": "a095086e52bc4617a1763a62398cd645", 80 | // "globalSequenceNumber": 2, 81 | // "streamSequenceNumber": 1, 82 | // "insertTimestamp": 1, 83 | // "eventID": "99cbd88bbcaf482ba1cc96ed12541707", 84 | // "eventType": "AnotherWasComplete", 85 | // "data": { 86 | // "id": "a095086e52bc4617a1763a62398cd645" 87 | // }, 88 | // "metadata": null 89 | // } 90 | // ] 91 | } 92 | -------------------------------------------------------------------------------- /pkg/rangedbapi/get_events_by_aggregate_type_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/jsontools" 15 | "github.com/inklabs/rangedb/pkg/rangedbapi" 16 | "github.com/inklabs/rangedb/provider/inmemorystore" 17 | "github.com/inklabs/rangedb/rangedbtest" 18 | ) 19 | 20 | func Example_getEventsByAggregateType() { 21 | // Given 22 | rangedbtest.SetRand(100) 23 | inMemoryStore := inmemorystore.New( 24 | inmemorystore.WithClock(sequentialclock.New()), 25 | inmemorystore.WithUUIDGenerator(rangedbtest.NewSeededUUIDGenerator()), 26 | ) 27 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 28 | PrintError(err) 29 | 30 | server := httptest.NewServer(api) 31 | defer server.Close() 32 | 33 | serverURL, err := url.Parse(server.URL) 34 | PrintError(err) 35 | serverURL.Path = "/events-by-aggregate-type/thing.json" 36 | 37 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 38 | defer cancel() 39 | 40 | streamNameA := "thing-605f20348fb940e386c171d51c877bf1" 41 | streamNameB := "another-a095086e52bc4617a1763a62398cd645" 42 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameA, 43 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "605f20348fb940e386c171d51c877bf1", Number: 100}}, 44 | ))) 45 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameB, 46 | &rangedb.EventRecord{Event: rangedbtest.AnotherWasComplete{ID: "a095086e52bc4617a1763a62398cd645"}}, 47 | ))) 48 | 49 | // When 50 | response, err := http.Get(serverURL.String()) 51 | PrintError(err) 52 | defer Close(response.Body) 53 | 54 | body, err := ioutil.ReadAll(response.Body) 55 | PrintError(err) 56 | 57 | fmt.Println(jsontools.PrettyJSON(body)) 58 | 59 | // Output: 60 | // [ 61 | // { 62 | // "streamName": "thing-605f20348fb940e386c171d51c877bf1", 63 | // "aggregateType": "thing", 64 | // "aggregateID": "605f20348fb940e386c171d51c877bf1", 65 | // "globalSequenceNumber": 1, 66 | // "streamSequenceNumber": 1, 67 | // "insertTimestamp": 0, 68 | // "eventID": "d2ba8e70072943388203c438d4e94bf3", 69 | // "eventType": "ThingWasDone", 70 | // "data": { 71 | // "id": "605f20348fb940e386c171d51c877bf1", 72 | // "number": 100 73 | // }, 74 | // "metadata": null 75 | // } 76 | // ] 77 | } 78 | -------------------------------------------------------------------------------- /pkg/rangedbapi/get_events_by_stream_ndjson_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 14 | "github.com/inklabs/rangedb/pkg/rangedbapi" 15 | "github.com/inklabs/rangedb/provider/inmemorystore" 16 | "github.com/inklabs/rangedb/rangedbtest" 17 | ) 18 | 19 | func Example_getEventsByStreamNdJson() { 20 | // Given 21 | rangedbtest.SetRand(100) 22 | inMemoryStore := inmemorystore.New( 23 | inmemorystore.WithClock(sequentialclock.New()), 24 | inmemorystore.WithUUIDGenerator(rangedbtest.NewSeededUUIDGenerator()), 25 | ) 26 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 27 | PrintError(err) 28 | 29 | server := httptest.NewServer(api) 30 | defer server.Close() 31 | 32 | serverURL, err := url.Parse(server.URL) 33 | PrintError(err) 34 | serverURL.Path = "/events-by-stream/thing-605f20348fb940e386c171d51c877bf1.ndjson" 35 | 36 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 37 | defer done() 38 | 39 | streamNameA := "thing-605f20348fb940e386c171d51c877bf1" 40 | streamNameB := "another-a095086e52bc4617a1763a62398cd645" 41 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameA, 42 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "605f20348fb940e386c171d51c877bf1", Number: 100}}, 43 | &rangedb.EventRecord{Event: rangedbtest.ThingWasDone{ID: "605f20348fb940e386c171d51c877bf1", Number: 200}}, 44 | ))) 45 | PrintError(IgnoreFirstNumber(inMemoryStore.Save(ctx, streamNameB, 46 | &rangedb.EventRecord{Event: rangedbtest.AnotherWasComplete{ID: "a095086e52bc4617a1763a62398cd645"}}, 47 | ))) 48 | 49 | // When 50 | response, err := http.Get(serverURL.String()) 51 | PrintError(err) 52 | defer Close(response.Body) 53 | 54 | body, err := ioutil.ReadAll(response.Body) 55 | PrintError(err) 56 | 57 | fmt.Println(string(body)) 58 | 59 | // Output: 60 | // {"streamName":"thing-605f20348fb940e386c171d51c877bf1","aggregateType":"thing","aggregateID":"605f20348fb940e386c171d51c877bf1","globalSequenceNumber":1,"streamSequenceNumber":1,"insertTimestamp":0,"eventID":"d2ba8e70072943388203c438d4e94bf3","eventType":"ThingWasDone","data":{"id":"605f20348fb940e386c171d51c877bf1","number":100},"metadata":null} 61 | // {"streamName":"thing-605f20348fb940e386c171d51c877bf1","aggregateType":"thing","aggregateID":"605f20348fb940e386c171d51c877bf1","globalSequenceNumber":2,"streamSequenceNumber":2,"insertTimestamp":1,"eventID":"99cbd88bbcaf482ba1cc96ed12541707","eventType":"ThingWasDone","data":{"id":"605f20348fb940e386c171d51c877bf1","number":200},"metadata":null} 62 | } 63 | -------------------------------------------------------------------------------- /pkg/rangedbapi/helper_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | func Close(c io.Closer) { 9 | err := c.Close() 10 | if err != nil { 11 | fmt.Printf("failed closing: %v", err) 12 | } 13 | } 14 | 15 | func PrintError(errors ...error) { 16 | for _, err := range errors { 17 | if err != nil { 18 | fmt.Println(err) 19 | } 20 | } 21 | } 22 | 23 | func IgnoreFirstNumber(_ uint64, err error) error { 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /pkg/rangedbapi/save_events_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 12 | "github.com/inklabs/rangedb/pkg/jsontools" 13 | "github.com/inklabs/rangedb/pkg/rangedbapi" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | ) 16 | 17 | func Example_saveEvent() { 18 | // Given 19 | inMemoryStore := inmemorystore.New( 20 | inmemorystore.WithClock(sequentialclock.New()), 21 | ) 22 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 23 | PrintError(err) 24 | 25 | server := httptest.NewServer(api) 26 | defer server.Close() 27 | 28 | serverURL, err := url.Parse(server.URL) 29 | PrintError(err) 30 | serverURL.Path = "/save-events/thing-141b39d2b9854f8093ef79dc47dae6af" 31 | 32 | const requestBody = `[ 33 | { 34 | "eventType": "ThingWasDone", 35 | "data":{ 36 | "id": "141b39d2b9854f8093ef79dc47dae6af", 37 | "number": 100 38 | }, 39 | "metadata":null 40 | }, 41 | { 42 | "eventType": "ThingWasDone", 43 | "data":{ 44 | "id": "141b39d2b9854f8093ef79dc47dae6af", 45 | "number": 200 46 | }, 47 | "metadata":null 48 | } 49 | ]` 50 | 51 | // When 52 | response, err := http.Post(serverURL.String(), "application/json", strings.NewReader(requestBody)) 53 | PrintError(err) 54 | defer Close(response.Body) 55 | 56 | body, err := ioutil.ReadAll(response.Body) 57 | PrintError(err) 58 | fmt.Println(jsontools.PrettyJSON(body)) 59 | 60 | // Output: 61 | // { 62 | // "status": "OK", 63 | // "streamSequenceNumber": 2 64 | // } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/rangedbapi/save_events_with_optimistic_concurrency_failure_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 12 | "github.com/inklabs/rangedb/pkg/jsontools" 13 | "github.com/inklabs/rangedb/pkg/rangedbapi" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | ) 16 | 17 | func Example_optimisticSaveEvents_failure() { 18 | // Given 19 | inMemoryStore := inmemorystore.New( 20 | inmemorystore.WithClock(sequentialclock.New()), 21 | ) 22 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 23 | PrintError(err) 24 | 25 | server := httptest.NewServer(api) 26 | defer server.Close() 27 | 28 | serverURL, err := url.Parse(server.URL) 29 | PrintError(err) 30 | serverURL.Path = "/save-events/thing-141b39d2b9854f8093ef79dc47dae6af" 31 | 32 | const requestBody = `[ 33 | { 34 | "eventType": "ThingWasDone", 35 | "data":{ 36 | "id": "141b39d2b9854f8093ef79dc47dae6af", 37 | "number": 100 38 | }, 39 | "metadata":null 40 | }, 41 | { 42 | "eventType": "ThingWasDone", 43 | "data":{ 44 | "id": "141b39d2b9854f8093ef79dc47dae6af", 45 | "number": 200 46 | }, 47 | "metadata":null 48 | } 49 | ]` 50 | 51 | request, err := http.NewRequest(http.MethodPost, serverURL.String(), strings.NewReader(requestBody)) 52 | PrintError(err) 53 | request.Header.Set("Content-Type", "application/json") 54 | request.Header.Set("ExpectedStreamSequenceNumber", "2") 55 | client := http.DefaultClient 56 | 57 | // When 58 | response, err := client.Do(request) 59 | PrintError(err) 60 | defer Close(response.Body) 61 | 62 | body, err := ioutil.ReadAll(response.Body) 63 | PrintError(err) 64 | fmt.Println(response.Status) 65 | fmt.Println(jsontools.PrettyJSON(body)) 66 | 67 | // Output: 68 | // 409 Conflict 69 | // { 70 | // "status": "Failed", 71 | // "message": "unexpected sequence number: 2, actual: 0" 72 | // } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/rangedbapi/save_events_with_optimistic_concurrency_test.go: -------------------------------------------------------------------------------- 1 | package rangedbapi_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/inklabs/rangedb/pkg/clock/provider/sequentialclock" 12 | "github.com/inklabs/rangedb/pkg/jsontools" 13 | "github.com/inklabs/rangedb/pkg/rangedbapi" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | ) 16 | 17 | func Example_optimisticSaveEvents() { 18 | // Given 19 | inMemoryStore := inmemorystore.New( 20 | inmemorystore.WithClock(sequentialclock.New()), 21 | ) 22 | api, err := rangedbapi.New(rangedbapi.WithStore(inMemoryStore)) 23 | PrintError(err) 24 | 25 | server := httptest.NewServer(api) 26 | defer server.Close() 27 | 28 | serverURL, err := url.Parse(server.URL) 29 | PrintError(err) 30 | serverURL.Path = "/save-events/thing-141b39d2b9854f8093ef79dc47dae6af" 31 | 32 | const requestBody = `[ 33 | { 34 | "eventType": "ThingWasDone", 35 | "data":{ 36 | "id": "141b39d2b9854f8093ef79dc47dae6af", 37 | "number": 100 38 | }, 39 | "metadata":null 40 | }, 41 | { 42 | "eventType": "ThingWasDone", 43 | "data":{ 44 | "id": "141b39d2b9854f8093ef79dc47dae6af", 45 | "number": 200 46 | }, 47 | "metadata":null 48 | } 49 | ]` 50 | 51 | request, err := http.NewRequest(http.MethodPost, serverURL.String(), strings.NewReader(requestBody)) 52 | PrintError(err) 53 | request.Header.Set("Content-Type", "application/json") 54 | request.Header.Set("ExpectedStreamSequenceNumber", "0") 55 | client := http.DefaultClient 56 | 57 | // When 58 | response, err := client.Do(request) 59 | PrintError(err) 60 | defer Close(response.Body) 61 | 62 | body, err := ioutil.ReadAll(response.Body) 63 | PrintError(err) 64 | fmt.Println(jsontools.PrettyJSON(body)) 65 | 66 | // Output: 67 | // { 68 | // "status": "OK", 69 | // "streamSequenceNumber": 2 70 | // } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/rangedberror/errors.go: -------------------------------------------------------------------------------- 1 | package rangedberror 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // UnexpectedSequenceNumber is an error containing expected and actual sequence numbers. 9 | type UnexpectedSequenceNumber struct { 10 | Expected uint64 11 | ActualSequenceNumber uint64 12 | } 13 | 14 | // NewUnexpectedSequenceNumberFromString constructs an UnexpectedSequenceNumber error. 15 | func NewUnexpectedSequenceNumberFromString(input string) *UnexpectedSequenceNumber { 16 | pieces := strings.Split(input, "unexpected sequence number:") 17 | if len(pieces) < 2 { 18 | return &UnexpectedSequenceNumber{} 19 | } 20 | 21 | var expected, actual uint64 22 | _, err := fmt.Sscanf(pieces[1], "%d, actual: %d", &expected, &actual) 23 | if err != nil { 24 | return &UnexpectedSequenceNumber{} 25 | } 26 | 27 | return &UnexpectedSequenceNumber{ 28 | Expected: expected, 29 | ActualSequenceNumber: actual, 30 | } 31 | } 32 | 33 | func (e UnexpectedSequenceNumber) Error() string { 34 | return fmt.Sprintf("unexpected sequence number: %d, actual: %d", 35 | e.Expected, 36 | e.ActualSequenceNumber, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/rangedberror/errors_test.go: -------------------------------------------------------------------------------- 1 | package rangedberror_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/inklabs/rangedb/pkg/rangedberror" 10 | ) 11 | 12 | func TestUnexpectedSequenceNumber_NewFromString(t *testing.T) { 13 | t.Run("normal error", func(t *testing.T) { 14 | // Given 15 | input := "unable to save to store: unexpected sequence number: 1, actual: 0" 16 | 17 | // When 18 | actual := rangedberror.NewUnexpectedSequenceNumberFromString(input) 19 | 20 | // Then 21 | require.NotNil(t, actual) 22 | assert.Equal(t, uint64(1), actual.Expected) 23 | assert.Equal(t, uint64(0), actual.ActualSequenceNumber) 24 | assert.Equal(t, "unexpected sequence number: 1, actual: 0", actual.Error()) 25 | }) 26 | 27 | t.Run("exotic error", func(t *testing.T) { 28 | // Given 29 | input := "some rpc error: unable to save to store: unexpected sequence number: 1, actual: 0" 30 | 31 | // When 32 | actual := rangedberror.NewUnexpectedSequenceNumberFromString(input) 33 | 34 | // Then 35 | require.NotNil(t, actual) 36 | assert.Equal(t, uint64(1), actual.Expected) 37 | assert.Equal(t, uint64(0), actual.ActualSequenceNumber) 38 | }) 39 | 40 | t.Run("unable to parse", func(t *testing.T) { 41 | // Given 42 | input := "invalid input" 43 | 44 | // When 45 | actual := rangedberror.NewUnexpectedSequenceNumberFromString(input) 46 | 47 | // Then 48 | require.NotNil(t, actual) 49 | assert.Equal(t, uint64(0), actual.Expected) 50 | assert.Equal(t, uint64(0), actual.ActualSequenceNumber) 51 | }) 52 | 53 | t.Run("unable to scan", func(t *testing.T) { 54 | // Given 55 | input := "unable to save to store: unexpected sequence number: xyz, actual: !@#" 56 | 57 | // When 58 | actual := rangedberror.NewUnexpectedSequenceNumberFromString(input) 59 | 60 | // Then 61 | require.NotNil(t, actual) 62 | assert.Equal(t, uint64(0), actual.Expected) 63 | assert.Equal(t, uint64(0), actual.ActualSequenceNumber) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/rangedbui/functions.go: -------------------------------------------------------------------------------- 1 | package rangedbui 2 | 3 | import ( 4 | "encoding/json" 5 | "html/template" 6 | "time" 7 | 8 | "github.com/dustin/go-humanize" 9 | 10 | "github.com/inklabs/rangedb" 11 | ) 12 | 13 | // FuncMap defines the functions available to templates. 14 | var FuncMap = template.FuncMap{ 15 | "formatDate": formatDate, 16 | "formatJson": formatJson, 17 | "formatUint64": formatUint64, 18 | "rangeDBVersion": func() string { 19 | return rangedb.Version 20 | }, 21 | } 22 | 23 | func formatDate(timestamp uint64, layout string) string { 24 | return time.Unix(int64(timestamp), 0).UTC().Format(layout) 25 | } 26 | 27 | func formatJson(v interface{}) string { 28 | bytes, _ := json.Marshal(v) 29 | return string(bytes) 30 | } 31 | 32 | func formatUint64(v uint64) string { 33 | return humanize.Comma(int64(v)) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/rangedbui/gen/pack-templates/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "encoding/base64" 7 | "flag" 8 | "io" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "text/template" 14 | ) 15 | 16 | func main() { 17 | templatesPath := flag.String("path", ".", "path to templates") 18 | outPath := flag.String("out", ".", "destination path") 19 | packageName := flag.String("package", os.Getenv("GOPACKAGE"), "package name") 20 | flag.Parse() 21 | 22 | templates := make(map[string]string) 23 | 24 | err := filepath.Walk(*templatesPath, 25 | func(path string, info os.FileInfo, err error) error { 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if info.IsDir() { 31 | return nil 32 | } 33 | 34 | filePath := strings.TrimPrefix(path, "templates/") 35 | templates[filePath] = readFileIntoBase64String(path) 36 | return nil 37 | }) 38 | if err != nil { 39 | log.Println(err) 40 | } 41 | 42 | outputFilePath := *outPath + "/templates_gen.go" 43 | file, err := os.Create(outputFilePath) 44 | if err != nil { 45 | log.Fatalf("unable to create templates file (%s): %v", outputFilePath, err) 46 | } 47 | 48 | err = fileTemplate.Execute(file, templateData{ 49 | PackageName: *packageName, 50 | Templates: templates, 51 | }) 52 | if err != nil { 53 | log.Fatalf("unable to write to events file: %v", err) 54 | } 55 | 56 | closeOrLog(file) 57 | } 58 | 59 | func readFileIntoBase64String(filePath string) string { 60 | file, err := os.Open(filePath) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | defer closeOrLog(file) 65 | 66 | var buf bytes.Buffer 67 | base64Encoder := base64.NewEncoder(base64.StdEncoding, &buf) 68 | zlibWriter := zlib.NewWriter(base64Encoder) 69 | 70 | _, err = io.Copy(zlibWriter, file) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | _ = zlibWriter.Flush() 75 | _ = zlibWriter.Close() 76 | _ = base64Encoder.Close() 77 | 78 | return buf.String() 79 | } 80 | 81 | func closeOrLog(c io.Closer) { 82 | err := c.Close() 83 | if err != nil { 84 | log.Printf("failed closing: %v", err) 85 | } 86 | } 87 | 88 | type templateData struct { 89 | PackageName string 90 | Templates map[string]string 91 | } 92 | 93 | var fileTemplate = template.Must(template.New("").Parse(`// Code generated by go generate 94 | package {{ .PackageName }} 95 | 96 | func GetTemplates() map[string]string { 97 | return map[string]string{ {{- range $key, $value := .Templates }} 98 | "{{ $key }}": "{{ $value }}",{{ end }} 99 | } 100 | } 101 | `)) 102 | -------------------------------------------------------------------------------- /pkg/rangedbui/static/css/foundation-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/rangedb/d4a1cabdbbd7f5654d2e7c79092c4e9b35b10f65/pkg/rangedbui/static/css/foundation-icons.woff -------------------------------------------------------------------------------- /pkg/rangedbui/static/css/site.css: -------------------------------------------------------------------------------- 1 | #header, #header a { 2 | color: #F2F5F6; 3 | } 4 | 5 | #header, #header .menu { 6 | background-color: #365975; 7 | } 8 | 9 | #content { 10 | margin-top: 1em; 11 | } 12 | 13 | #footer { 14 | margin-top: 2em; 15 | } 16 | 17 | .top-bar { 18 | padding: 0 10px; 19 | } 20 | 21 | .top-bar-title { 22 | font-size: 24px; 23 | line-height: 28px; 24 | } 25 | 26 | .top-bar-title img { 27 | vertical-align: bottom; 28 | } 29 | 30 | .records th { 31 | white-space: nowrap; 32 | } 33 | 34 | .records .date { 35 | white-space: nowrap; 36 | font-size: x-small; 37 | } 38 | 39 | .records .event-data { 40 | font-size: small; 41 | } 42 | -------------------------------------------------------------------------------- /pkg/rangedbui/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/rangedb/d4a1cabdbbd7f5654d2e7c79092c4e9b35b10f65/pkg/rangedbui/static/img/favicon.ico -------------------------------------------------------------------------------- /pkg/rangedbui/static/img/rangedb-logo-white-30x30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inklabs/rangedb/d4a1cabdbbd7f5654d2e7c79092c4e9b35b10f65/pkg/rangedbui/static/img/rangedb-logo-white-30x30.png -------------------------------------------------------------------------------- /pkg/rangedbui/templates/aggregate-type.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/rangedb/pkg/rangedbui.aggregateTypeTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle"}}{{.AggregateTypeInfo.Name}}{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 | 21 | {{template "records" .Records}} 22 | {{template "pagination" .PaginationLinks}} 23 |
24 |
25 |
26 | {{end}} 27 | -------------------------------------------------------------------------------- /pkg/rangedbui/templates/aggregate-types.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/rangedb/pkg/rangedbui.aggregateTypesTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle" }}Aggregate Types{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {{range .AggregateTypes}} 29 | 30 | 31 | 32 | 33 | {{end}} 34 | 35 |
TypeTotal Events
{{.Name}}{{.TotalEvents | formatUint64}}
36 |
    37 |
38 |
39 |
40 |
41 | {{end}} 42 | -------------------------------------------------------------------------------- /pkg/rangedbui/templates/layout/base.gohtml: -------------------------------------------------------------------------------- 1 | {{define "base"}} 2 | 3 | 4 | 5 | 6 | 7 | {{block "pageTitle" .}}{{end}} - RangeDB 8 | 9 | 10 | 11 | 12 | 13 | {{block "extraHead" .}}{{end}} 14 | 15 | 16 | 31 | 32 |
33 | {{block "content" .}}{{end}} 34 |
35 | 36 | 47 | 48 | {{block "extraEndBody" .}}{{end}} 49 | 50 | 51 | 52 | {{end}} 53 | -------------------------------------------------------------------------------- /pkg/rangedbui/templates/layout/pagination.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/rangedb/pkg/paging.Links*/ -}} 2 | 3 | {{define "pagination"}} 4 | 18 | {{end}} 19 | -------------------------------------------------------------------------------- /pkg/rangedbui/templates/layout/records.gohtml: -------------------------------------------------------------------------------- 1 | {{define "records"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{range .}} 17 | {{- /*gotype: github.com/inklabs/rangedb.Record*/ -}} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{end}} 28 | 29 |
GSNSSNEvent TypeAggregate TypeStreamInsert DateData
{{.GlobalSequenceNumber}}{{.StreamSequenceNumber}}{{.EventType}}{{.AggregateType}}{{.StreamName}}{{formatDate .InsertTimestamp "Jan 02, 2006 15:04:05 UTC"}}{{formatJson .Data}}
30 | {{end}} 31 | -------------------------------------------------------------------------------- /pkg/rangedbui/templates/stream.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/inklabs/rangedb/pkg/rangedbui.streamTemplateVars*/ -}} 2 | 3 | {{template "base" .}} 4 | {{define "pageTitle"}}{{.StreamInfo.Name}}{{end}} 5 | 6 | {{define "content"}} 7 |
8 |
9 |
10 | 17 | {{template "records" .Records}} 18 | {{template "pagination" .PaginationLinks}} 19 |
20 |
21 |
22 | {{end}} 23 | -------------------------------------------------------------------------------- /pkg/rangedbws/helper_test.go: -------------------------------------------------------------------------------- 1 | package rangedbws_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | func Close(c io.Closer) { 9 | err := c.Close() 10 | if err != nil { 11 | fmt.Printf("failed closing: %v", err) 12 | } 13 | } 14 | 15 | func PrintError(errors ...error) { 16 | for _, err := range errors { 17 | if err != nil { 18 | fmt.Println(err) 19 | } 20 | } 21 | } 22 | 23 | func IgnoreFirstNumber(_ uint64, err error) error { 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /pkg/rangedbws/websocket_private_test.go: -------------------------------------------------------------------------------- 1 | package rangedbws 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "math" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/inklabs/rangedb" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | "github.com/inklabs/rangedb/rangedbtest" 16 | ) 17 | 18 | func Test_Private_broadcastRecord(t *testing.T) { 19 | t.Run("when unable to marshal json", func(t *testing.T) { 20 | // Given 21 | var logBuffer bytes.Buffer 22 | logger := log.New(&logBuffer, "", 0) 23 | api, err := New( 24 | WithStore(inmemorystore.New()), 25 | WithLogger(logger), 26 | ) 27 | require.NoError(t, err) 28 | t.Cleanup(api.Stop) 29 | record := &rangedb.Record{ 30 | Data: math.Inf(1), 31 | } 32 | 33 | // When 34 | err = api.broadcastRecord(nil, record) 35 | 36 | // Then 37 | assert.EqualError(t, err, "unable to marshal record: json: unsupported value: +Inf") 38 | assert.Equal(t, "unable to marshal record: json: unsupported value: +Inf\n", logBuffer.String()) 39 | }) 40 | 41 | t.Run("when unable to send message to client", func(t *testing.T) { 42 | // Given 43 | var logBuffer bytes.Buffer 44 | logger := log.New(&logBuffer, "", 0) 45 | api, err := New( 46 | WithStore(inmemorystore.New()), 47 | WithLogger(logger), 48 | ) 49 | require.NoError(t, err) 50 | t.Cleanup(api.Stop) 51 | record := rangedbtest.DummyRecord() 52 | failingMessageWriter := newFailingMessageWriter() 53 | 54 | // When 55 | err = api.broadcastRecord(failingMessageWriter, record) 56 | 57 | // Then 58 | assert.EqualError(t, err, "unable to send record to WebSocket client: failingMessageWriter.WriteMessage") 59 | assert.Equal(t, "unable to send record to WebSocket client: failingMessageWriter.WriteMessage\n", logBuffer.String()) 60 | }) 61 | } 62 | 63 | type failingMessageWriter struct{} 64 | 65 | func newFailingMessageWriter() *failingMessageWriter { 66 | return &failingMessageWriter{} 67 | } 68 | 69 | func (f failingMessageWriter) WriteMessage(_ int, _ []byte) error { 70 | return fmt.Errorf("failingMessageWriter.WriteMessage") 71 | } 72 | -------------------------------------------------------------------------------- /pkg/recordsubscriber/config.go: -------------------------------------------------------------------------------- 1 | package recordsubscriber 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/broadcast" 8 | ) 9 | 10 | // Config defines a record subscription configuration. 11 | type Config struct { 12 | BufferSize int 13 | GetRecords GetRecordsIteratorFunc 14 | ConsumeRecord ConsumeRecordFunc 15 | Subscribe SubscribeFunc 16 | Unsubscribe SubscribeFunc 17 | Ctx context.Context 18 | } 19 | 20 | // AllEventsConfig returns a configuration to subscribe to all events. 21 | func AllEventsConfig(ctx context.Context, store rangedb.Store, broadcaster broadcast.Broadcaster, bufferLength int, consumeRecord ConsumeRecordFunc) Config { 22 | return Config{ 23 | BufferSize: bufferLength, 24 | Ctx: ctx, 25 | Subscribe: func(subscriber broadcast.RecordSubscriber) { 26 | broadcaster.SubscribeAllEvents(subscriber) 27 | }, 28 | Unsubscribe: func(subscriber broadcast.RecordSubscriber) { 29 | broadcaster.UnsubscribeAllEvents(subscriber) 30 | }, 31 | GetRecords: func(globalSequenceNumber uint64) rangedb.RecordIterator { 32 | return store.Events(ctx, globalSequenceNumber) 33 | }, 34 | ConsumeRecord: consumeRecord, 35 | } 36 | } 37 | 38 | // AggregateTypesConfig returns a configuration to subscribe to events by aggregate types. 39 | func AggregateTypesConfig(ctx context.Context, store rangedb.Store, broadcaster broadcast.Broadcaster, bufferLength int, aggregateTypes []string, consumeRecord ConsumeRecordFunc) Config { 40 | return Config{ 41 | BufferSize: bufferLength, 42 | Ctx: ctx, 43 | Subscribe: func(subscriber broadcast.RecordSubscriber) { 44 | broadcaster.SubscribeAggregateTypes(subscriber, aggregateTypes...) 45 | }, 46 | Unsubscribe: func(subscriber broadcast.RecordSubscriber) { 47 | broadcaster.UnsubscribeAggregateTypes(subscriber, aggregateTypes...) 48 | }, 49 | GetRecords: func(globalSequenceNumber uint64) rangedb.RecordIterator { 50 | return store.EventsByAggregateTypes(ctx, globalSequenceNumber, aggregateTypes...) 51 | }, 52 | ConsumeRecord: consumeRecord, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/recordsubscriber/config_test.go: -------------------------------------------------------------------------------- 1 | package recordsubscriber_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/inklabs/rangedb" 11 | "github.com/inklabs/rangedb/pkg/broadcast" 12 | "github.com/inklabs/rangedb/pkg/recordsubscriber" 13 | "github.com/inklabs/rangedb/provider/inmemorystore" 14 | ) 15 | 16 | func TestConfig(t *testing.T) { 17 | // Given 18 | ctx := context.Background() 19 | store := inmemorystore.New() 20 | broadcaster := broadcast.New(1, time.Nanosecond) 21 | bufferLength := 1 22 | consumeRecord := func(record *rangedb.Record) error { 23 | return nil 24 | } 25 | var aggregateTypes []string 26 | 27 | // When 28 | allEventsConfig := recordsubscriber.AllEventsConfig(ctx, store, broadcaster, bufferLength, consumeRecord) 29 | aggregateTypesConfig := recordsubscriber.AggregateTypesConfig(ctx, store, broadcaster, bufferLength, aggregateTypes, consumeRecord) 30 | 31 | // Then 32 | assert.IsType(t, recordsubscriber.Config{}, allEventsConfig) 33 | assert.IsType(t, recordsubscriber.Config{}, aggregateTypesConfig) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/shortuuid/short_uuid.go: -------------------------------------------------------------------------------- 1 | package shortuuid 2 | 3 | import ( 4 | "encoding/hex" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // ShortUUID is a type that can stringify a UUID without hyphens. 10 | type ShortUUID uuid.UUID 11 | 12 | // New constructs a ShortUUID object. 13 | func New() ShortUUID { 14 | return ShortUUID(uuid.New()) 15 | } 16 | 17 | func (u ShortUUID) String() string { 18 | buf := make([]byte, 32) 19 | hex.Encode(buf[:], u[:]) 20 | return string(buf) 21 | } 22 | 23 | type Generator interface { 24 | // New returns a new ShortUUID string 25 | New() string 26 | } 27 | 28 | type uuidGenerator struct{} 29 | 30 | func NewUUIDGenerator() *uuidGenerator { 31 | return &uuidGenerator{} 32 | } 33 | 34 | func (g *uuidGenerator) New() string { 35 | return New().String() 36 | } 37 | -------------------------------------------------------------------------------- /pkg/shortuuid/short_uuid_test.go: -------------------------------------------------------------------------------- 1 | package shortuuid_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/inklabs/rangedb/rangedbtest" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/inklabs/rangedb/pkg/shortuuid" 11 | ) 12 | 13 | func Test_NewToString(t *testing.T) { 14 | // Given 15 | rangedbtest.SetRand(100) 16 | u := shortuuid.New() 17 | 18 | // When 19 | output := u.String() 20 | 21 | // Then 22 | assert.Equal(t, "d2ba8e70072943388203c438d4e94bf3", output) 23 | } 24 | 25 | func ExampleShortUUID_String() { 26 | // Given 27 | rangedbtest.SetRand(101) 28 | u := shortuuid.New() 29 | 30 | // When 31 | fmt.Println(u.String()) 32 | 33 | // Output: 34 | // b03b2442c5de4e58b6c62e8094847f3d 35 | } 36 | -------------------------------------------------------------------------------- /pkg/structparser/struct_names.go: -------------------------------------------------------------------------------- 1 | package structparser 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "io" 9 | ) 10 | 11 | // GetStructNames loads struct names from an io.Reader. 12 | func GetStructNames(file io.Reader) ([]string, error) { 13 | node, err := parser.ParseFile(token.NewFileSet(), "", file, parser.ParseComments) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed parsing: %v", err) 16 | } 17 | 18 | structVisitor := &structVisitor{} 19 | ast.Walk(structVisitor, node) 20 | 21 | return structVisitor.Names, nil 22 | } 23 | 24 | type structVisitor struct { 25 | Names []string 26 | } 27 | 28 | func (v *structVisitor) Visit(node ast.Node) (w ast.Visitor) { 29 | switch n := node.(type) { 30 | case *ast.TypeSpec: 31 | if _, ok := n.Type.(*ast.StructType); ok { 32 | v.Names = append(v.Names, n.Name.String()) 33 | } 34 | } 35 | 36 | return v 37 | } 38 | -------------------------------------------------------------------------------- /pkg/structparser/struct_names_test.go: -------------------------------------------------------------------------------- 1 | package structparser_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/inklabs/rangedb/pkg/structparser" 11 | ) 12 | 13 | func TestGetStructNames(t *testing.T) { 14 | t.Run("returns empty list", func(t *testing.T) { 15 | // Given 16 | code := `package test` 17 | content := strings.NewReader(code) 18 | 19 | // When 20 | structNames, err := structparser.GetStructNames(content) 21 | 22 | // Then 23 | require.NoError(t, err) 24 | assert.Equal(t, 0, len(structNames)) 25 | }) 26 | 27 | t.Run("returns one struct name with other keywords present", func(t *testing.T) { 28 | // Given 29 | code := `package test 30 | type Struct1 struct {} 31 | type Interface1 interface {} 32 | func Func1() {}` 33 | content := strings.NewReader(code) 34 | 35 | // When 36 | structNames, err := structparser.GetStructNames(content) 37 | 38 | // Then 39 | require.NoError(t, err) 40 | assert.Equal(t, 1, len(structNames)) 41 | assert.Equal(t, []string{"Struct1"}, structNames) 42 | }) 43 | 44 | t.Run("returns two struct names", func(t *testing.T) { 45 | // Given 46 | code := `package test 47 | type Struct1 struct {} 48 | type Struct2 struct{}` 49 | content := strings.NewReader(code) 50 | 51 | // When 52 | structNames, err := structparser.GetStructNames(content) 53 | 54 | // Then 55 | require.NoError(t, err) 56 | assert.Equal(t, []string{"Struct1", "Struct2"}, structNames) 57 | }) 58 | 59 | t.Run("fails with empty reader", func(t *testing.T) { 60 | // Given 61 | emptyReader := strings.NewReader("") 62 | 63 | // When 64 | structNames, err := structparser.GetStructNames(emptyReader) 65 | 66 | // Then 67 | assert.Nil(t, structNames) 68 | assert.EqualError(t, err, "failed parsing: 1:1: expected 'package', found 'EOF'") 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /provider/encryptedstore/decrypting_record_iterator.go: -------------------------------------------------------------------------------- 1 | package encryptedstore 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/pkg/crypto" 8 | ) 9 | 10 | type decryptingRecordIterator struct { 11 | parent rangedb.RecordIterator 12 | eventEncryptor crypto.EventEncryptor 13 | currentErr error 14 | } 15 | 16 | // NewDecryptingRecordIterator constructs a new rangedb.Record iterator that decrypts events 17 | func NewDecryptingRecordIterator(parent rangedb.RecordIterator, eventEncryptor crypto.EventEncryptor) *decryptingRecordIterator { 18 | return &decryptingRecordIterator{ 19 | parent: parent, 20 | eventEncryptor: eventEncryptor, 21 | } 22 | } 23 | 24 | func (i *decryptingRecordIterator) Next() bool { 25 | if i.currentErr != nil { 26 | return false 27 | } 28 | 29 | return i.parent.Next() 30 | } 31 | 32 | func (i *decryptingRecordIterator) NextContext(ctx context.Context) bool { 33 | if i.currentErr != nil { 34 | return false 35 | } 36 | 37 | return i.parent.NextContext(ctx) 38 | } 39 | 40 | func (i *decryptingRecordIterator) Record() *rangedb.Record { 41 | record := i.parent.Record() 42 | 43 | if record == nil { 44 | return nil 45 | } 46 | 47 | if rangedbEvent, ok := record.Data.(rangedb.Event); ok { 48 | err := i.eventEncryptor.Decrypt(rangedbEvent) 49 | if err != nil { 50 | i.currentErr = err 51 | return nil 52 | } 53 | } 54 | 55 | return record 56 | } 57 | 58 | func (i *decryptingRecordIterator) Err() error { 59 | if i.currentErr != nil { 60 | return i.currentErr 61 | } 62 | 63 | return i.parent.Err() 64 | } 65 | -------------------------------------------------------------------------------- /provider/encryptedstore/decrypting_record_subscriber.go: -------------------------------------------------------------------------------- 1 | package encryptedstore 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | "github.com/inklabs/rangedb/pkg/crypto" 6 | ) 7 | 8 | type decryptingRecordSubscriber struct { 9 | parent rangedb.RecordSubscriber 10 | eventEncryptor crypto.EventEncryptor 11 | } 12 | 13 | // NewDecryptingRecordSubscriber decrypts records on Accept 14 | func NewDecryptingRecordSubscriber(parent rangedb.RecordSubscriber, eventEncryptor crypto.EventEncryptor) *decryptingRecordSubscriber { 15 | return &decryptingRecordSubscriber{ 16 | parent: parent, 17 | eventEncryptor: eventEncryptor, 18 | } 19 | } 20 | 21 | func (d *decryptingRecordSubscriber) Accept(record *rangedb.Record) { 22 | if rangedbEvent, ok := record.Data.(rangedb.Event); ok { 23 | _ = d.eventEncryptor.Decrypt(rangedbEvent) 24 | } 25 | 26 | d.parent.Accept(record) 27 | } 28 | -------------------------------------------------------------------------------- /provider/encryptedstore/encrypt_event_test.go: -------------------------------------------------------------------------------- 1 | package encryptedstore_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | 8 | "github.com/inklabs/rangedb" 9 | "github.com/inklabs/rangedb/pkg/crypto/aes" 10 | "github.com/inklabs/rangedb/pkg/crypto/cryptotest" 11 | "github.com/inklabs/rangedb/pkg/crypto/eventencryptor" 12 | "github.com/inklabs/rangedb/pkg/crypto/provider/inmemorykeystore" 13 | "github.com/inklabs/rangedb/provider/encryptedstore" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | ) 16 | 17 | func ExampleNew_automatically_encrypt_decrypt() { 18 | // Given 19 | seededRandReader := rand.New(rand.NewSource(100)) 20 | aesEncryptor := aes.NewGCM() 21 | aesEncryptor.SetRandReader(seededRandReader) 22 | keyStore := inmemorykeystore.New() 23 | eventEncryptor := eventencryptor.New(keyStore, aesEncryptor) 24 | eventEncryptor.SetRandReader(seededRandReader) 25 | event := &cryptotest.CustomerSignedUp{ 26 | ID: "fe65ac8d8c3a45e9b3cee407f10ee518", 27 | Name: "John Doe", 28 | Email: "john@example.com", 29 | Status: "active", 30 | } 31 | inMemoryStore := inmemorystore.New() 32 | encryptedStore := encryptedstore.New(inMemoryStore, eventEncryptor) 33 | encryptedStore.Bind(&cryptotest.CustomerSignedUp{}) 34 | streamName := rangedb.GetEventStream(event) 35 | 36 | ctx := context.Background() 37 | 38 | // When 39 | _, err := encryptedStore.Save(ctx, streamName, &rangedb.EventRecord{Event: event}) 40 | PrintError(err) 41 | 42 | fmt.Println("Auto Decrypted Event:") 43 | recordIterator := encryptedStore.Events(ctx, 0) 44 | for recordIterator.Next() { 45 | PrintEvent(recordIterator.Record().Data.(rangedb.Event)) 46 | } 47 | 48 | fmt.Println("Raw Encrypted Event:") 49 | rawRecordIterator := inMemoryStore.Events(ctx, 0) 50 | for rawRecordIterator.Next() { 51 | PrintEvent(rawRecordIterator.Record().Data.(rangedb.Event)) 52 | } 53 | 54 | // Output: 55 | // Auto Decrypted Event: 56 | // { 57 | // "ID": "fe65ac8d8c3a45e9b3cee407f10ee518", 58 | // "Name": "John Doe", 59 | // "Email": "john@example.com", 60 | // "Status": "active" 61 | // } 62 | // Raw Encrypted Event: 63 | // { 64 | // "ID": "fe65ac8d8c3a45e9b3cee407f10ee518", 65 | // "Name": "Lp5pGK8QGYw3NJyJVBsW49HESSf+NEraAQoBmpLXboZvsN/L", 66 | // "Email": "o1H9t1BClYc5UcyUV+Roe3wz5gwRZRjgBI/xzwZs8ueQGQ5L8uGnbrTGrh8=", 67 | // "Status": "active" 68 | // } 69 | } 70 | -------------------------------------------------------------------------------- /provider/encryptedstore/helper_test.go: -------------------------------------------------------------------------------- 1 | package encryptedstore_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/inklabs/rangedb" 8 | "github.com/inklabs/rangedb/pkg/jsontools" 9 | ) 10 | 11 | func PrintError(errors ...error) { 12 | for _, err := range errors { 13 | if err != nil { 14 | fmt.Println(err) 15 | } 16 | } 17 | } 18 | 19 | func PrintEvent(event rangedb.Event) { 20 | body, err := json.Marshal(event) 21 | PrintError(err) 22 | 23 | fmt.Println(jsontools.PrettyJSON(body)) 24 | } 25 | -------------------------------------------------------------------------------- /provider/eventstore/README.md: -------------------------------------------------------------------------------- 1 | 2 | # EventStoreDB Implementation 3 | 4 | ## Run locally for tests 5 | 6 | ```bash 7 | docker run -it -p 2113:2113 -p 1113:1113 eventstore/eventstore:21.10.0-bionic --insecure --mem-db 8 | ``` 9 | 10 | ## Run locally with functional UI features 11 | 12 | ```bash 13 | docker run -it -p 2113:2113 -p 1113:1113 eventstore/eventstore:21.10.0-bionic --insecure --run-projections=All --enable-atom-pub-over-http=true 14 | ``` 15 | -------------------------------------------------------------------------------- /provider/eventstore/eventstore_test.go: -------------------------------------------------------------------------------- 1 | package eventstore_test 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/inklabs/rangedb/pkg/shortuuid" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/inklabs/rangedb" 14 | "github.com/inklabs/rangedb/pkg/clock" 15 | "github.com/inklabs/rangedb/provider/eventstore" 16 | "github.com/inklabs/rangedb/rangedbtest" 17 | ) 18 | 19 | func Test_EventStore_VerifyStoreInterface(t *testing.T) { 20 | config := getConfigFromEnvironment(t) 21 | 22 | esStore, err := eventstore.New(config) 23 | require.NoError(t, err) 24 | err = esStore.Ping() 25 | if err != nil { 26 | t.Skip("EventStoreDB not found. Run: docker run -it -p 2113:2113 -p 1113:1113 eventstore/eventstore:21.10.0-bionic --insecure --run-projections=All --enable-atom-pub-over-http=true") 27 | } 28 | 29 | verifier := rangedbtest.NewStoreVerifier(rangedbtest.GSNStyleMonotonicSequence) 30 | verifier.Verify(t, func(t *testing.T, clock clock.Clock, uuidGenerator shortuuid.Generator) rangedb.Store { 31 | streamPrefixer := newIncrementingStreamPrefixer() 32 | esStore, err := eventstore.New(config, 33 | eventstore.WithClock(clock), 34 | eventstore.WithUUIDGenerator(uuidGenerator), 35 | eventstore.WithStreamPrefix(streamPrefixer), 36 | eventstore.RecordDeletedStreams(), 37 | ) 38 | 39 | require.NoError(t, err) 40 | rangedbtest.BindEvents(esStore) 41 | 42 | t.Cleanup(func() { 43 | streamPrefixer.TickVersion() 44 | require.NoError(t, err) 45 | require.NoError(t, esStore.Close()) 46 | }) 47 | 48 | return esStore 49 | }) 50 | } 51 | 52 | type incrementingStreamPrefixer struct { 53 | mux sync.RWMutex 54 | version int64 55 | } 56 | 57 | func newIncrementingStreamPrefixer() *incrementingStreamPrefixer { 58 | return &incrementingStreamPrefixer{ 59 | version: time.Now().UnixNano(), 60 | } 61 | } 62 | 63 | func (p *incrementingStreamPrefixer) WithPrefix(name string) string { 64 | p.mux.RLock() 65 | defer p.mux.RUnlock() 66 | 67 | return fmt.Sprintf("%d-%s", p.version, name) 68 | } 69 | 70 | func (p *incrementingStreamPrefixer) GetPrefix() string { 71 | p.mux.RLock() 72 | defer p.mux.RUnlock() 73 | 74 | return fmt.Sprintf("%d-", p.version) 75 | } 76 | 77 | func (p *incrementingStreamPrefixer) TickVersion() { 78 | p.mux.Lock() 79 | p.version++ 80 | p.mux.Unlock() 81 | } 82 | 83 | func getConfigFromEnvironment(t *testing.T) eventstore.Config { 84 | config, err := eventstore.NewConfigFromEnvironment() 85 | if err != nil { 86 | // docker run -p 8200:8200 -e 'VAULT_DEV_ROOT_TOKEN_ID=testroot' vault:1.9.1 87 | t.Skip(err.Error(), "ESDB_IP_ADDR, ESDB_USERNAME, and ESDB_PASSWORD are required") 88 | } 89 | 90 | return config 91 | } 92 | -------------------------------------------------------------------------------- /provider/inmemorystore/inmemory_store_test.go: -------------------------------------------------------------------------------- 1 | package inmemorystore_test 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/inklabs/rangedb/pkg/shortuuid" 11 | 12 | "github.com/inklabs/rangedb" 13 | "github.com/inklabs/rangedb/pkg/clock" 14 | "github.com/inklabs/rangedb/provider/inmemorystore" 15 | "github.com/inklabs/rangedb/rangedbtest" 16 | ) 17 | 18 | func Test_InMemory_VerifyStoreInterface(t *testing.T) { 19 | verifier := rangedbtest.NewStoreVerifier(rangedbtest.GSNStyleExact) 20 | verifier.Verify(t, func(t *testing.T, clock clock.Clock, uuidGenerator shortuuid.Generator) rangedb.Store { 21 | store := inmemorystore.New( 22 | inmemorystore.WithClock(clock), 23 | inmemorystore.WithUUIDGenerator(uuidGenerator), 24 | ) 25 | rangedbtest.BindEvents(store) 26 | 27 | return store 28 | }) 29 | } 30 | 31 | func BenchmarkInMemoryStore(b *testing.B) { 32 | rangedbtest.StoreBenchmark(b, func(b *testing.B) rangedb.Store { 33 | store := inmemorystore.New() 34 | rangedbtest.BindEvents(store) 35 | return store 36 | }) 37 | } 38 | 39 | func Test_Failures(t *testing.T) { 40 | t.Run("SaveEvent fails when serialize fails", func(t *testing.T) { 41 | // Given 42 | store := inmemorystore.New( 43 | inmemorystore.WithSerializer(rangedbtest.NewFailingSerializer()), 44 | ) 45 | event := rangedbtest.ThingWasDone{} 46 | streamName := rangedb.GetEventStream(event) 47 | ctx := rangedbtest.TimeoutContext(t) 48 | 49 | // When 50 | lastStreamSequenceNumber, err := store.Save(ctx, streamName, &rangedb.EventRecord{Event: event}) 51 | 52 | // Then 53 | assert.EqualError(t, err, "failingSerializer.Serialize") 54 | assert.Equal(t, uint64(0), lastStreamSequenceNumber) 55 | }) 56 | 57 | t.Run("EventsByStream errors when deserialize fails", func(t *testing.T) { 58 | // Given 59 | var logBuffer bytes.Buffer 60 | logger := log.New(&logBuffer, "", 0) 61 | store := inmemorystore.New( 62 | inmemorystore.WithSerializer(rangedbtest.NewFailingDeserializer()), 63 | inmemorystore.WithLogger(logger), 64 | ) 65 | event := rangedbtest.ThingWasDone{} 66 | ctx := rangedbtest.TimeoutContext(t) 67 | streamName := rangedb.GetEventStream(event) 68 | rangedbtest.SaveEvents(t, store, streamName, &rangedb.EventRecord{Event: event}) 69 | 70 | // When 71 | recordIterator := store.EventsByStream(ctx, 0, rangedb.GetEventStream(event)) 72 | 73 | // Then 74 | assert.False(t, recordIterator.Next()) 75 | assert.EqualError(t, recordIterator.Err(), "failed to deserialize record: failingDeserializer.Deserialize") 76 | assert.Nil(t, recordIterator.Record()) 77 | assert.Equal(t, "failingDeserializer.Deserialize\nfailed to deserialize record: failingDeserializer.Deserialize\n", logBuffer.String()) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /provider/jsonrecordiostream/json_record_io_stream.go: -------------------------------------------------------------------------------- 1 | package jsonrecordiostream 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "reflect" 8 | 9 | "github.com/inklabs/rangedb" 10 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 11 | ) 12 | 13 | type jsonRecordIoStream struct { 14 | eventIdentifier rangedb.EventTypeIdentifier 15 | } 16 | 17 | // New constructs a jsonRecordIoStream. 18 | func New() *jsonRecordIoStream { 19 | return &jsonRecordIoStream{ 20 | eventIdentifier: rangedb.NewEventIdentifier(), 21 | } 22 | } 23 | 24 | func (s *jsonRecordIoStream) Write(writer io.Writer, recordIterator rangedb.RecordIterator) <-chan error { 25 | errors := make(chan error) 26 | 27 | go func() { 28 | defer close(errors) 29 | 30 | _, _ = fmt.Fprint(writer, "[") 31 | 32 | totalSaved := 0 33 | for recordIterator.Next() { 34 | if recordIterator.Err() != nil { 35 | errors <- recordIterator.Err() 36 | return 37 | } 38 | 39 | data, err := json.Marshal(recordIterator.Record()) 40 | if err != nil { 41 | errors <- fmt.Errorf("failed marshalling event: %v", err) 42 | return 43 | } 44 | 45 | if totalSaved > 0 { 46 | _, _ = fmt.Fprint(writer, ",") 47 | } 48 | 49 | _, _ = fmt.Fprintf(writer, "%s", data) 50 | 51 | totalSaved++ 52 | } 53 | _, _ = fmt.Fprint(writer, "]") 54 | }() 55 | 56 | return errors 57 | } 58 | 59 | func (s *jsonRecordIoStream) Read(reader io.Reader) rangedb.RecordIterator { 60 | resultRecords := make(chan rangedb.ResultRecord) 61 | 62 | go func() { 63 | defer close(resultRecords) 64 | 65 | decoder := json.NewDecoder(reader) 66 | decoder.UseNumber() 67 | 68 | _, err := decoder.Token() 69 | if err != nil { 70 | if err == io.EOF { 71 | return 72 | } 73 | resultRecords <- rangedb.ResultRecord{ 74 | Record: nil, 75 | Err: err, 76 | } 77 | return 78 | } 79 | 80 | for decoder.More() { 81 | record, err := jsonrecordserializer.UnmarshalRecord(decoder, s) 82 | if err != nil { 83 | resultRecords <- rangedb.ResultRecord{ 84 | Record: nil, 85 | Err: err, 86 | } 87 | return 88 | } 89 | 90 | // TODO: Add cancel context to avoid deadlock 91 | resultRecords <- rangedb.ResultRecord{ 92 | Record: record, 93 | Err: nil, 94 | } 95 | } 96 | }() 97 | 98 | return rangedb.NewRecordIterator(resultRecords) 99 | } 100 | 101 | func (s *jsonRecordIoStream) Bind(events ...rangedb.Event) { 102 | s.eventIdentifier.Bind(events...) 103 | } 104 | 105 | func (s *jsonRecordIoStream) EventTypeLookup(eventTypeName string) (r reflect.Type, b bool) { 106 | return s.eventIdentifier.EventTypeLookup(eventTypeName) 107 | } 108 | -------------------------------------------------------------------------------- /provider/jsonrecordserializer/json_record_serializer.go: -------------------------------------------------------------------------------- 1 | package jsonrecordserializer 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "reflect" 9 | 10 | "github.com/inklabs/rangedb" 11 | ) 12 | 13 | type jsonSerializer struct { 14 | eventIdentifier rangedb.EventTypeIdentifier 15 | } 16 | 17 | // New constructs a jsonSerializer. 18 | func New() *jsonSerializer { 19 | return &jsonSerializer{ 20 | eventIdentifier: rangedb.NewEventIdentifier(), 21 | } 22 | } 23 | 24 | func (s *jsonSerializer) Bind(events ...rangedb.Event) { 25 | s.eventIdentifier.Bind(events...) 26 | } 27 | 28 | func (s *jsonSerializer) Serialize(record *rangedb.Record) ([]byte, error) { 29 | data, err := json.Marshal(record) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed marshalling record: %v", err) 32 | } 33 | 34 | return data, nil 35 | } 36 | 37 | func (s *jsonSerializer) Deserialize(serializedData []byte) (*rangedb.Record, error) { 38 | decoder := json.NewDecoder(bytes.NewReader(serializedData)) 39 | decoder.UseNumber() 40 | 41 | return UnmarshalRecord(decoder, s.eventIdentifier) 42 | } 43 | 44 | func (s *jsonSerializer) EventTypeLookup(eventTypeName string) (reflect.Type, bool) { 45 | return s.eventIdentifier.EventTypeLookup(eventTypeName) 46 | } 47 | 48 | // UnmarshalRecord decodes a Record using the supplied JSON decoder. 49 | // 50 | // Event data will be parsed into a struct if supplied by getEventType. 51 | func UnmarshalRecord(decoder *json.Decoder, eventTypeIdentifier rangedb.EventTypeIdentifier) (*rangedb.Record, error) { 52 | var rawEvent json.RawMessage 53 | record := rangedb.Record{ 54 | Data: &rawEvent, 55 | } 56 | err := decoder.Decode(&record) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed unmarshalling record: %v", err) 59 | } 60 | 61 | data, err := DecodeJsonData(record.EventType, bytes.NewReader(rawEvent), eventTypeIdentifier) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed unmarshalling event within record: %v", err) 64 | } 65 | 66 | record.Data = data 67 | 68 | return &record, nil 69 | } 70 | 71 | // DecodeJsonData decodes raw json into a struct or interface{}. 72 | // 73 | // Event data will be parsed into a struct if supplied by getEventType. 74 | func DecodeJsonData(eventTypeName string, rawJsonData io.Reader, eventTypeIdentifier rangedb.EventTypeIdentifier) (interface{}, error) { 75 | dataDecoder := json.NewDecoder(rawJsonData) 76 | dataDecoder.UseNumber() 77 | 78 | eventType, ok := eventTypeIdentifier.EventTypeLookup(eventTypeName) 79 | if !ok { 80 | var data interface{} 81 | err := dataDecoder.Decode(&data) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return data, nil 87 | } 88 | 89 | data := reflect.New(eventType).Interface() 90 | err := dataDecoder.Decode(&data) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return data, nil 96 | } 97 | -------------------------------------------------------------------------------- /provider/msgpackrecordiostream/msgpack_record_io_stream.go: -------------------------------------------------------------------------------- 1 | package msgpackrecordiostream 2 | 3 | import ( 4 | "io" 5 | "reflect" 6 | 7 | "github.com/vmihailenco/msgpack/v4" 8 | 9 | "github.com/inklabs/rangedb" 10 | "github.com/inklabs/rangedb/provider/msgpackrecordserializer" 11 | ) 12 | 13 | type msgpackRecordIoStream struct { 14 | eventIdentifier rangedb.EventTypeIdentifier 15 | } 16 | 17 | // New constructs a msgpackRecordIoStream. 18 | func New() *msgpackRecordIoStream { 19 | return &msgpackRecordIoStream{ 20 | eventIdentifier: rangedb.NewEventIdentifier(), 21 | } 22 | } 23 | 24 | func (s *msgpackRecordIoStream) Bind(events ...rangedb.Event) { 25 | s.eventIdentifier.Bind(events...) 26 | } 27 | 28 | func (s *msgpackRecordIoStream) Write(writer io.Writer, recordIterator rangedb.RecordIterator) <-chan error { 29 | errors := make(chan error) 30 | 31 | go func() { 32 | defer close(errors) 33 | 34 | for recordIterator.Next() { 35 | if recordIterator.Err() != nil { 36 | errors <- recordIterator.Err() 37 | return 38 | } 39 | 40 | serializedRecord, err := msgpackrecordserializer.MarshalRecord(recordIterator.Record()) 41 | if err != nil { 42 | errors <- err 43 | return 44 | } 45 | 46 | _, _ = writer.Write(serializedRecord) 47 | } 48 | }() 49 | 50 | return errors 51 | } 52 | 53 | func (s *msgpackRecordIoStream) Read(reader io.Reader) rangedb.RecordIterator { 54 | resultRecords := make(chan rangedb.ResultRecord) 55 | 56 | go func() { 57 | defer close(resultRecords) 58 | 59 | decoder := msgpack.NewDecoder(reader) 60 | decoder.UseJSONTag(true) 61 | 62 | for { 63 | record, err := msgpackrecordserializer.UnmarshalRecord(decoder, s.eventIdentifier) 64 | if err != nil { 65 | if err == msgpackrecordserializer.ErrorEOF { 66 | return 67 | } 68 | 69 | resultRecords <- rangedb.ResultRecord{ 70 | Record: nil, 71 | Err: err, 72 | } 73 | return 74 | } 75 | 76 | // TODO: Add cancel context to avoid deadlock 77 | resultRecords <- rangedb.ResultRecord{ 78 | Record: record, 79 | Err: nil, 80 | } 81 | } 82 | }() 83 | 84 | return rangedb.NewRecordIterator(resultRecords) 85 | } 86 | 87 | func (s *msgpackRecordIoStream) EventTypeLookup(eventTypeName string) (r reflect.Type, b bool) { 88 | return s.eventIdentifier.EventTypeLookup(eventTypeName) 89 | } 90 | -------------------------------------------------------------------------------- /provider/ndjsonrecordiostream/ndjson_record_io_stream.go: -------------------------------------------------------------------------------- 1 | package ndjsonrecordiostream 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "reflect" 8 | 9 | "github.com/inklabs/rangedb" 10 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 11 | ) 12 | 13 | type ndJSONRecordIoStream struct { 14 | eventIdentifier rangedb.EventTypeIdentifier 15 | } 16 | 17 | // New constructs an ndjson implementation of rangedb.RecordIoStream. 18 | func New() *ndJSONRecordIoStream { 19 | return &ndJSONRecordIoStream{ 20 | eventIdentifier: rangedb.NewEventIdentifier(), 21 | } 22 | } 23 | 24 | func (s *ndJSONRecordIoStream) Write(writer io.Writer, recordIterator rangedb.RecordIterator) <-chan error { 25 | errors := make(chan error) 26 | 27 | go func() { 28 | defer close(errors) 29 | 30 | totalSaved := 0 31 | for recordIterator.Next() { 32 | if recordIterator.Err() != nil { 33 | errors <- recordIterator.Err() 34 | return 35 | } 36 | 37 | if totalSaved > 0 { 38 | _, _ = fmt.Fprint(writer, "\n") 39 | } 40 | 41 | data, err := json.Marshal(recordIterator.Record()) 42 | if err != nil { 43 | errors <- fmt.Errorf("failed marshalling event: %v", err) 44 | return 45 | } 46 | 47 | _, _ = fmt.Fprintf(writer, "%s", data) 48 | 49 | totalSaved++ 50 | } 51 | }() 52 | 53 | return errors 54 | } 55 | 56 | func (s *ndJSONRecordIoStream) Read(reader io.Reader) rangedb.RecordIterator { 57 | resultRecords := make(chan rangedb.ResultRecord) 58 | 59 | go func() { 60 | defer close(resultRecords) 61 | 62 | decoder := json.NewDecoder(reader) 63 | decoder.UseNumber() 64 | 65 | for decoder.More() { 66 | record, err := jsonrecordserializer.UnmarshalRecord(decoder, s) 67 | if err != nil { 68 | resultRecords <- rangedb.ResultRecord{ 69 | Record: nil, 70 | Err: err, 71 | } 72 | return 73 | } 74 | 75 | // TODO: Add cancel context to avoid deadlock 76 | resultRecords <- rangedb.ResultRecord{ 77 | Record: record, 78 | Err: nil, 79 | } 80 | } 81 | }() 82 | 83 | return rangedb.NewRecordIterator(resultRecords) 84 | } 85 | 86 | func (s *ndJSONRecordIoStream) Bind(events ...rangedb.Event) { 87 | s.eventIdentifier.Bind(events...) 88 | } 89 | 90 | func (s *ndJSONRecordIoStream) EventTypeLookup(eventTypeName string) (r reflect.Type, b bool) { 91 | return s.eventIdentifier.EventTypeLookup(eventTypeName) 92 | } 93 | -------------------------------------------------------------------------------- /provider/postgresstore/config.go: -------------------------------------------------------------------------------- 1 | package postgresstore 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // Config holds the state for a PostgreSQL DB config. 9 | type Config struct { 10 | Host string 11 | Port int 12 | User string 13 | Password string 14 | DBName string 15 | SearchPath string 16 | } 17 | 18 | // DataSourceName returns the DSN for a PostgreSQL DB. 19 | func (c Config) DataSourceName() string { 20 | searchPath := "" 21 | if c.SearchPath != "" { 22 | searchPath = fmt.Sprintf(" search_path=%s", c.SearchPath) 23 | } 24 | 25 | return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable%s", 26 | c.Host, c.Port, c.User, c.Password, c.DBName, searchPath) 27 | } 28 | 29 | // NewConfigFromEnvironment loads a Postgres config from environment variables. 30 | func NewConfigFromEnvironment() (*Config, error) { 31 | pgHost := os.Getenv("PG_HOST") 32 | pgUser := os.Getenv("PG_USER") 33 | pgPassword := os.Getenv("PG_PASSWORD") 34 | pgDBName := os.Getenv("PG_DBNAME") 35 | pgSearchPath := os.Getenv("PG_SEARCH_PATH") 36 | 37 | if pgHost == "" || pgUser == "" || pgDBName == "" { 38 | return nil, fmt.Errorf("postgreSQL DB has not been configured via environment variables") 39 | } 40 | 41 | return &Config{ 42 | Host: pgHost, 43 | Port: 5432, 44 | User: pgUser, 45 | Password: pgPassword, 46 | DBName: pgDBName, 47 | SearchPath: pgSearchPath, 48 | }, nil 49 | } 50 | -------------------------------------------------------------------------------- /provider/postgresstore/config_test.go: -------------------------------------------------------------------------------- 1 | package postgresstore_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/inklabs/rangedb/provider/postgresstore" 11 | ) 12 | 13 | func TestConfig(t *testing.T) { 14 | t.Run("new config errors", func(t *testing.T) { 15 | // Given 16 | key := "PG_HOST" 17 | origValue := os.Getenv(key) 18 | require.NoError(t, os.Setenv(key, "")) 19 | t.Cleanup(func() { 20 | require.NoError(t, os.Setenv(key, origValue)) 21 | }) 22 | 23 | // When 24 | config, err := postgresstore.NewConfigFromEnvironment() 25 | 26 | // Then 27 | require.EqualError(t, err, "postgreSQL DB has not been configured via environment variables") 28 | assert.Nil(t, config) 29 | }) 30 | 31 | t.Run("returns correct DSN", func(t *testing.T) { 32 | // Given 33 | config := &postgresstore.Config{ 34 | Host: "host", 35 | Port: 8080, 36 | User: "user", 37 | Password: "password", 38 | DBName: "dbname", 39 | } 40 | 41 | // When 42 | dsn := config.DataSourceName() 43 | 44 | // Then 45 | assert.Equal(t, "host=host port=8080 user=user password=password dbname=dbname sslmode=disable", dsn) 46 | }) 47 | 48 | t.Run("returns correct DSN", func(t *testing.T) { 49 | // Given 50 | config := &postgresstore.Config{ 51 | Host: "host", 52 | Port: 8080, 53 | User: "user", 54 | Password: "password", 55 | DBName: "dbname", 56 | SearchPath: "searchpath", 57 | } 58 | 59 | // When 60 | dsn := config.DataSourceName() 61 | 62 | // Then 63 | assert.Equal(t, "host=host port=8080 user=user password=password dbname=dbname sslmode=disable search_path=searchpath", dsn) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /provider/postgresstore/postgres_store_private_test.go: -------------------------------------------------------------------------------- 1 | package postgresstore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/inklabs/rangedb/rangedbtest" 13 | ) 14 | 15 | func TestPrivate(t *testing.T) { 16 | t.Run("lockStream errors when obtaining advisory lock", func(t *testing.T) { 17 | // Given 18 | config := configFromEnvironment(t) 19 | store, err := New(config) 20 | require.NoError(t, err) 21 | const aggregateID = "9a483cf6fc1c45b2a126c146498f519b" 22 | failingDB := newFailingDBExecAble() 23 | ctx := rangedbtest.TimeoutContext(t) 24 | 25 | // When 26 | err = store.lockStream(ctx, failingDB, aggregateID) 27 | 28 | // Then 29 | assert.EqualError(t, err, "unable to obtain lock: failingDBExecAble.ExecContext") 30 | }) 31 | 32 | t.Run("batchInsert errors from closed context", func(t *testing.T) { 33 | config := configFromEnvironment(t) 34 | store, err := New(config) 35 | require.NoError(t, err) 36 | failingDB := newFailingDBQueryable() 37 | ctx := rangedbtest.TimeoutContext(t) 38 | 39 | // When 40 | ints, lastStreamSequenceNumber, err := store.batchInsert(ctx, failingDB, nil) 41 | 42 | // Then 43 | assert.EqualError(t, err, "unable to insert: failingDBQueryable.QueryContext") 44 | assert.Empty(t, ints) 45 | assert.Equal(t, uint64(0), lastStreamSequenceNumber) 46 | }) 47 | 48 | } 49 | 50 | type failingDBQueryable struct{} 51 | 52 | func newFailingDBQueryable() *failingDBQueryable { 53 | return &failingDBQueryable{} 54 | } 55 | 56 | func (f failingDBQueryable) QueryContext(_ context.Context, _ string, _ ...interface{}) (*sql.Rows, error) { 57 | return nil, fmt.Errorf("failingDBQueryable.QueryContext") 58 | } 59 | 60 | type failingDBExecAble struct{} 61 | 62 | func newFailingDBExecAble() *failingDBExecAble { 63 | return &failingDBExecAble{} 64 | } 65 | 66 | func (f failingDBExecAble) ExecContext(_ context.Context, _ string, _ ...interface{}) (sql.Result, error) { 67 | return nil, fmt.Errorf("failingDBExecAble.ExecContext") 68 | } 69 | 70 | func configFromEnvironment(t *testing.T) *Config { 71 | config, err := NewConfigFromEnvironment() 72 | if err != nil { 73 | t.Skip("Postgres DB has not been configured via environment variables to run integration tests") 74 | } 75 | 76 | return config 77 | } 78 | -------------------------------------------------------------------------------- /publish_record.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // PublishRecordOrCancel publishes a Record to a ResultRecord channel, or times out. 9 | func PublishRecordOrCancel(ctx context.Context, resultRecords chan ResultRecord, record *Record, timeout time.Duration) bool { 10 | select { 11 | case <-ctx.Done(): 12 | select { 13 | case <-time.After(timeout): 14 | case resultRecords <- ResultRecord{Err: ctx.Err()}: 15 | } 16 | return false 17 | 18 | default: 19 | } 20 | 21 | select { 22 | case <-ctx.Done(): 23 | select { 24 | case <-time.After(timeout): 25 | case resultRecords <- ResultRecord{Err: ctx.Err()}: 26 | } 27 | return false 28 | 29 | case resultRecords <- ResultRecord{Record: record}: 30 | } 31 | 32 | return true 33 | } 34 | -------------------------------------------------------------------------------- /rangedbtest/benchmark_record_serializer.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/inklabs/rangedb" 9 | ) 10 | 11 | // RecordSerializerBenchmark benchmarks the rangedb.RecordSerializer interface. 12 | func RecordSerializerBenchmark(b *testing.B, newSerializer func() rangedb.RecordSerializer) { 13 | b.Helper() 14 | 15 | serializer := newSerializer() 16 | BindEvents(serializer) 17 | record := &rangedb.Record{ 18 | StreamName: "thing-c2077176843a49189ae0d746eb131e05", 19 | AggregateType: "thing", 20 | AggregateID: "c2077176843a49189ae0d746eb131e05", 21 | GlobalSequenceNumber: 1, 22 | StreamSequenceNumber: 1, 23 | InsertTimestamp: 0, 24 | EventID: "0899fed048964c2f9c398d7ef623f0c7", 25 | EventType: "ThingWasDone", 26 | Data: ThingWasDone{ 27 | ID: "c2077176843a49189ae0d746eb131e05", 28 | Number: 100, 29 | }, 30 | Metadata: nil, 31 | } 32 | serializedRecord, err := serializer.Serialize(record) 33 | require.NoError(b, err) 34 | 35 | b.Run("Serialize", func(b *testing.B) { 36 | for i := 0; i < b.N; i++ { 37 | _, err := serializer.Serialize(record) 38 | if err != nil { 39 | require.NoError(b, err) 40 | } 41 | } 42 | }) 43 | 44 | b.Run("Deserialize", func(b *testing.B) { 45 | for i := 0; i < b.N; i++ { 46 | _, err := serializer.Deserialize(serializedRecord) 47 | if err != nil { 48 | require.NoError(b, err) 49 | } 50 | } 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /rangedbtest/bind_events_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go generate; DO NOT EDIT. 2 | // This file was generated at 3 | // 2021-01-20 15:37:20.832346 -0800 PST m=+0.001145698 4 | package rangedbtest 5 | 6 | import "github.com/inklabs/rangedb" 7 | 8 | func BindEvents(binder rangedb.EventBinder) { 9 | binder.Bind( 10 | &ThingWasDone{}, 11 | &AnotherWasComplete{}, 12 | &ThatWasDone{}, 13 | &FloatWasDone{}, 14 | &StringWasDone{}, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /rangedbtest/blocking_subscriber.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "github.com/inklabs/rangedb" 5 | ) 6 | 7 | type blockingSubscriber struct { 8 | Records chan *rangedb.Record 9 | parent rangedb.RecordSubscriber 10 | } 11 | 12 | // NewBlockingSubscriber constructs a RecordSubscriber that blocks on Accept into Records. 13 | func NewBlockingSubscriber(parent rangedb.RecordSubscriber) *blockingSubscriber { 14 | return &blockingSubscriber{ 15 | Records: make(chan *rangedb.Record, 10), 16 | parent: parent, 17 | } 18 | } 19 | 20 | func (b blockingSubscriber) Accept(record *rangedb.Record) { 21 | if b.parent != nil { 22 | b.parent.Accept(record) 23 | } 24 | b.Records <- record 25 | } 26 | -------------------------------------------------------------------------------- /rangedbtest/cmd/random-data/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "os/signal" 11 | 12 | "google.golang.org/grpc" 13 | 14 | "github.com/inklabs/rangedb/pkg/grpc/rangedbpb" 15 | "github.com/inklabs/rangedb/pkg/shortuuid" 16 | ) 17 | 18 | func main() { 19 | host := flag.String("host", "127.0.0.1:8081", "RangeDB gRPC host address") 20 | flag.Parse() 21 | 22 | conn, err := grpc.Dial(*host, grpc.WithInsecure()) 23 | if err != nil { 24 | log.Fatalf("unable to dial (%s): %v", *host, err) 25 | } 26 | 27 | rangeDBClient := rangedbpb.NewRangeDBClient(conn) 28 | ctx := context.Background() 29 | 30 | done := make(chan struct{}, 1) 31 | stop := make(chan os.Signal, 1) 32 | signal.Notify(stop, os.Interrupt) 33 | 34 | totalEvents := uint32(0) 35 | 36 | go func() { 37 | defer close(done) 38 | 39 | aggregateType := "foo" 40 | aggregateID := shortuuid.New().String() 41 | for { 42 | request := &rangedbpb.SaveRequest{ 43 | StreamName: fmt.Sprintf("%s-%s", aggregateType, aggregateID), 44 | Events: getRandomEvents(aggregateType, aggregateID), 45 | } 46 | 47 | response, err := rangeDBClient.Save(ctx, request) 48 | if err != nil { 49 | log.Println(err) 50 | return 51 | } 52 | 53 | fmt.Println(response) 54 | 55 | totalEvents += response.EventsSaved 56 | 57 | select { 58 | case <-stop: 59 | return 60 | default: 61 | } 62 | } 63 | }() 64 | 65 | <-done 66 | 67 | fmt.Printf("Sent %d events\n", totalEvents) 68 | } 69 | 70 | func getRandomEvents(aggregateType, aggregateID string) []*rangedbpb.Event { 71 | total := rand.Intn(99) + 1 72 | events := make([]*rangedbpb.Event, total) 73 | for i := range events { 74 | events[i] = &rangedbpb.Event{ 75 | AggregateType: aggregateType, 76 | AggregateID: aggregateID, 77 | EventType: "FooBar", 78 | Data: fmt.Sprintf(`{"number":%d}`, i), 79 | Metadata: "", 80 | } 81 | } 82 | 83 | return events 84 | } 85 | -------------------------------------------------------------------------------- /rangedbtest/context.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type cleaner interface { 9 | Cleanup(f func()) 10 | } 11 | 12 | // TimeoutContext returns a context that will timeout. 13 | func TimeoutContext(c cleaner) context.Context { 14 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 15 | c.Cleanup(done) 16 | return ctx 17 | } 18 | -------------------------------------------------------------------------------- /rangedbtest/failing_event_store.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/inklabs/rangedb" 8 | ) 9 | 10 | type failingEventStore struct{} 11 | 12 | // NewFailingEventStore constructs a failing event store for testing. 13 | func NewFailingEventStore() *failingEventStore { 14 | return &failingEventStore{} 15 | } 16 | 17 | func (f failingEventStore) Bind(_ ...rangedb.Event) {} 18 | 19 | func (f failingEventStore) Events(_ context.Context, _ uint64) rangedb.RecordIterator { 20 | return getFailingIterator("Events") 21 | } 22 | 23 | func (f failingEventStore) EventsByAggregateTypes(_ context.Context, _ uint64, _ ...string) rangedb.RecordIterator { 24 | return getFailingIterator("EventsByAggregateTypes") 25 | } 26 | 27 | func (f failingEventStore) EventsByStream(_ context.Context, _ uint64, _ string) rangedb.RecordIterator { 28 | return getFailingIterator("EventsByStream") 29 | } 30 | 31 | func (f failingEventStore) OptimisticDeleteStream(_ context.Context, _ uint64, _ string) error { 32 | return fmt.Errorf("failingEventStore.OptimisitDeleteStream") 33 | } 34 | 35 | func (f failingEventStore) OptimisticSave(_ context.Context, _ uint64, _ string, _ ...*rangedb.EventRecord) (uint64, error) { 36 | return 0, fmt.Errorf("failingEventStore.OptimisticSave") 37 | } 38 | 39 | func (f failingEventStore) Save(_ context.Context, _ string, _ ...*rangedb.EventRecord) (uint64, error) { 40 | return 0, fmt.Errorf("failingEventStore.Save") 41 | } 42 | 43 | func (f failingEventStore) AllEventsSubscription(_ context.Context, _ int, _ rangedb.RecordSubscriber) rangedb.RecordSubscription { 44 | return &dummyRecordSubscription{} 45 | } 46 | 47 | func (f failingEventStore) AggregateTypesSubscription(_ context.Context, _ int, _ rangedb.RecordSubscriber, _ ...string) rangedb.RecordSubscription { 48 | return &dummyRecordSubscription{} 49 | } 50 | 51 | func (f failingEventStore) TotalEventsInStream(_ context.Context, _ string) (uint64, error) { 52 | return 0, fmt.Errorf("failingEventStore.TotalEventsInStream") 53 | } 54 | 55 | func getFailingIterator(name string) rangedb.RecordIterator { 56 | recordResults := make(chan rangedb.ResultRecord, 1) 57 | recordResults <- rangedb.ResultRecord{ 58 | Record: nil, 59 | Err: fmt.Errorf("failingEventStore.%s", name), 60 | } 61 | close(recordResults) 62 | return rangedb.NewRecordIterator(recordResults) 63 | } 64 | -------------------------------------------------------------------------------- /rangedbtest/failing_serializer.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/inklabs/rangedb" 7 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 8 | ) 9 | 10 | type failingSerializer struct{} 11 | 12 | // NewFailingSerializer constructs a failing rangedb.RecordSerializer. 13 | func NewFailingSerializer() *failingSerializer { 14 | return &failingSerializer{} 15 | } 16 | 17 | // Serialize always fails when called. 18 | func (f *failingSerializer) Serialize(_ *rangedb.Record) ([]byte, error) { 19 | return nil, fmt.Errorf("failingSerializer.Serialize") 20 | } 21 | 22 | // Deserialize uses the json record serializer to deserialize. 23 | func (f *failingSerializer) Deserialize(data []byte) (*rangedb.Record, error) { 24 | return jsonrecordserializer.New().Deserialize(data) 25 | } 26 | 27 | func (f *failingSerializer) Bind(_ ...rangedb.Event) {} 28 | 29 | type failingDeserializer struct{} 30 | 31 | // NewFailingDeserializer constructs a failing rangedb.RecordSerializer. 32 | func NewFailingDeserializer() *failingDeserializer { 33 | return &failingDeserializer{} 34 | } 35 | 36 | // Serialize uses the json record serializer to serialize. 37 | func (f *failingDeserializer) Serialize(record *rangedb.Record) ([]byte, error) { 38 | return jsonrecordserializer.New().Serialize(record) 39 | } 40 | 41 | // Deserialize always fails when called. 42 | func (f *failingDeserializer) Deserialize(_ []byte) (*rangedb.Record, error) { 43 | return nil, fmt.Errorf("failingDeserializer.Deserialize") 44 | } 45 | 46 | func (f *failingDeserializer) Bind(_ ...rangedb.Event) {} 47 | -------------------------------------------------------------------------------- /rangedbtest/failing_serializer_test.go: -------------------------------------------------------------------------------- 1 | package rangedbtest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/inklabs/rangedb" 10 | "github.com/inklabs/rangedb/provider/jsonrecordserializer" 11 | "github.com/inklabs/rangedb/rangedbtest" 12 | ) 13 | 14 | func Test_FailingSerializer(t *testing.T) { 15 | t.Run("deserializes correctly", func(t *testing.T) { 16 | // Given 17 | record := &rangedb.Record{Data: &rangedbtest.ThingWasDone{}} 18 | jsonSerializer := jsonrecordserializer.New() 19 | serializedData, err := jsonSerializer.Serialize(record) 20 | require.NoError(t, err) 21 | serializer := rangedbtest.NewFailingSerializer() 22 | serializer.Bind(&rangedbtest.ThingWasDone{}) 23 | 24 | // When 25 | actualRecord, err := serializer.Deserialize(serializedData) 26 | 27 | // Then 28 | require.NoError(t, err) 29 | reSerializedData, err := jsonSerializer.Serialize(actualRecord) 30 | require.NoError(t, err) 31 | assert.Equal(t, serializedData, reSerializedData) 32 | }) 33 | } 34 | 35 | func Test_FailingDeserializer(t *testing.T) { 36 | t.Run("serializes correctly", func(t *testing.T) { 37 | // Given 38 | record := &rangedb.Record{Data: &rangedbtest.ThingWasDone{}} 39 | jsonSerializer := jsonrecordserializer.New() 40 | serializedData, err := jsonSerializer.Serialize(record) 41 | require.NoError(t, err) 42 | serializer := rangedbtest.NewFailingDeserializer() 43 | serializer.Bind(&rangedbtest.ThingWasDone{}) 44 | 45 | // When 46 | actualSerializedData, err := serializer.Serialize(record) 47 | 48 | // Then 49 | require.NoError(t, err) 50 | assert.Equal(t, serializedData, actualSerializedData) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /rangedbtest/failing_subscribe_store.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/inklabs/rangedb" 8 | ) 9 | 10 | type failingSubscribeEventStore struct{} 11 | 12 | // NewFailingSubscribeEventStore constructs a failing event store for testing. 13 | func NewFailingSubscribeEventStore() *failingSubscribeEventStore { 14 | return &failingSubscribeEventStore{} 15 | } 16 | 17 | func (f failingSubscribeEventStore) Bind(_ ...rangedb.Event) {} 18 | 19 | func (f failingSubscribeEventStore) Events(_ context.Context, _ uint64) rangedb.RecordIterator { 20 | return getEmptyIterator() 21 | } 22 | 23 | func (f failingSubscribeEventStore) EventsByAggregateTypes(_ context.Context, _ uint64, _ ...string) rangedb.RecordIterator { 24 | return getEmptyIterator() 25 | } 26 | 27 | func (f failingSubscribeEventStore) EventsByStream(_ context.Context, _ uint64, _ string) rangedb.RecordIterator { 28 | return getEmptyIterator() 29 | } 30 | 31 | func (f failingSubscribeEventStore) OptimisticDeleteStream(_ context.Context, _ uint64, _ string) error { 32 | return nil 33 | } 34 | 35 | func (f failingSubscribeEventStore) OptimisticSave(_ context.Context, _ uint64, _ string, _ ...*rangedb.EventRecord) (uint64, error) { 36 | return 0, nil 37 | } 38 | 39 | func (f failingSubscribeEventStore) Save(_ context.Context, _ string, _ ...*rangedb.EventRecord) (uint64, error) { 40 | return 0, nil 41 | } 42 | 43 | func (f failingSubscribeEventStore) AllEventsSubscription(_ context.Context, _ int, _ rangedb.RecordSubscriber) rangedb.RecordSubscription { 44 | return &failingRecordSubscription{} 45 | } 46 | 47 | func (f failingSubscribeEventStore) AggregateTypesSubscription(_ context.Context, _ int, _ rangedb.RecordSubscriber, _ ...string) rangedb.RecordSubscription { 48 | return &failingRecordSubscription{} 49 | } 50 | 51 | func (f failingSubscribeEventStore) TotalEventsInStream(_ context.Context, _ string) (uint64, error) { 52 | return 0, nil 53 | } 54 | 55 | func getEmptyIterator() rangedb.RecordIterator { 56 | recordResults := make(chan rangedb.ResultRecord) 57 | return rangedb.NewRecordIterator(recordResults) 58 | } 59 | 60 | type failingRecordSubscription struct{} 61 | 62 | func (f failingRecordSubscription) Start() error { 63 | return fmt.Errorf("failingRecordSubscription.Start") 64 | } 65 | 66 | func (f failingRecordSubscription) StartFrom(_ uint64) error { 67 | return fmt.Errorf("failingRecordSubscription.StartFrom") 68 | } 69 | 70 | func (f failingRecordSubscription) Stop() {} 71 | 72 | type dummyRecordSubscription struct{} 73 | 74 | func (f dummyRecordSubscription) Start() error { 75 | return nil 76 | } 77 | 78 | func (f dummyRecordSubscription) StartFrom(_ uint64) error { 79 | return nil 80 | } 81 | 82 | func (f dummyRecordSubscription) Stop() {} 83 | -------------------------------------------------------------------------------- /rangedbtest/seeded_uuid_generator.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "math/rand" 5 | "sync" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/inklabs/rangedb/pkg/shortuuid" 10 | ) 11 | 12 | type seededUUIDGenerator struct { 13 | mux sync.RWMutex 14 | generatedUUIDs []string 15 | } 16 | 17 | func NewSeededUUIDGenerator() *seededUUIDGenerator { 18 | return &seededUUIDGenerator{} 19 | } 20 | 21 | func (g *seededUUIDGenerator) New() string { 22 | newUUID := shortuuid.New().String() 23 | 24 | g.mux.Lock() 25 | g.generatedUUIDs = append(g.generatedUUIDs, newUUID) 26 | g.mux.Unlock() 27 | 28 | return newUUID 29 | } 30 | 31 | func (g *seededUUIDGenerator) Get(index int) string { 32 | g.mux.RLock() 33 | defer g.mux.RUnlock() 34 | 35 | if len(g.generatedUUIDs) < index { 36 | return "" 37 | } 38 | 39 | return g.generatedUUIDs[index-1] 40 | } 41 | 42 | func SetRand(seed int64) { 43 | uuid.SetRand(rand.New(rand.NewSource(seed))) 44 | } 45 | -------------------------------------------------------------------------------- /rangedbtest/total_events_subscriber.go: -------------------------------------------------------------------------------- 1 | package rangedbtest 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/inklabs/rangedb" 7 | ) 8 | 9 | type totalEventsSubscriber struct { 10 | sync sync.RWMutex 11 | totalEvents int 12 | } 13 | 14 | // NewTotalEventsSubscriber constructs a projection to count total events received. 15 | func NewTotalEventsSubscriber() *totalEventsSubscriber { 16 | return &totalEventsSubscriber{} 17 | } 18 | 19 | // Accept receives a Record. 20 | func (s *totalEventsSubscriber) Accept(_ *rangedb.Record) { 21 | s.sync.Lock() 22 | s.totalEvents++ 23 | s.sync.Unlock() 24 | } 25 | 26 | // TotalEvents returns the total number of events received. 27 | func (s *totalEventsSubscriber) TotalEvents() int { 28 | s.sync.RLock() 29 | defer s.sync.RUnlock() 30 | 31 | return s.totalEvents 32 | } 33 | -------------------------------------------------------------------------------- /record_io_stream.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // RecordIoStream is the interface that (de)serializes a stream of Records. 8 | type RecordIoStream interface { 9 | Read(io.Reader) RecordIterator 10 | Write(io.Writer, RecordIterator) <-chan error 11 | Bind(events ...Event) 12 | } 13 | -------------------------------------------------------------------------------- /record_iterator.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type recordIterator struct { 8 | resultRecords <-chan ResultRecord 9 | current ResultRecord 10 | } 11 | 12 | // NewRecordIterator constructs a new rangedb.Record iterator 13 | func NewRecordIterator(recordResult <-chan ResultRecord) *recordIterator { 14 | return &recordIterator{resultRecords: recordResult} 15 | } 16 | 17 | func (i *recordIterator) Next() bool { 18 | if i.current.Err != nil { 19 | return false 20 | } 21 | 22 | i.current = <-i.resultRecords 23 | 24 | return i.current.Record != nil 25 | } 26 | 27 | func (i *recordIterator) NextContext(ctx context.Context) bool { 28 | if i.current.Err != nil { 29 | return false 30 | } 31 | 32 | select { 33 | case i.current = <-i.resultRecords: 34 | case <-ctx.Done(): 35 | i.current = ResultRecord{ 36 | Record: nil, 37 | Err: ctx.Err(), 38 | } 39 | } 40 | 41 | return i.current.Record != nil 42 | } 43 | 44 | func (i *recordIterator) Record() *Record { 45 | return i.current.Record 46 | } 47 | 48 | func (i *recordIterator) Err() error { 49 | return i.current.Err 50 | } 51 | 52 | func NewRecordIteratorWithError(err error) *recordIterator { 53 | records := make(chan ResultRecord, 1) 54 | records <- ResultRecord{Err: err} 55 | close(records) 56 | return NewRecordIterator(records) 57 | } 58 | -------------------------------------------------------------------------------- /record_serializer.go: -------------------------------------------------------------------------------- 1 | package rangedb 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // RecordSerializer is the interface that (de)serializes Records. 8 | type RecordSerializer interface { 9 | Serialize(record *Record) ([]byte, error) 10 | Deserialize(data []byte) (*Record, error) 11 | Bind(events ...Event) 12 | } 13 | 14 | // EventTypeIdentifier is the interface for retrieving an event type. 15 | type EventTypeIdentifier interface { 16 | EventBinder 17 | EventTypeLookup(eventTypeName string) (reflect.Type, bool) 18 | } 19 | --------------------------------------------------------------------------------