├── .buildkite └── docker-image.yml ├── .changeset ├── README.md └── config.json ├── .env.dev ├── .github └── workflows │ ├── changesets_release.yml │ ├── elixir_tests.yml │ ├── integration_tests.yml │ └── ts_test.yml ├── .gitignore ├── .prettierrc ├── .support ├── docker-compose.yml └── postgres.conf ├── .tool-versions ├── LICENSE ├── LIMITATIONS.md ├── README.md ├── docs ├── .vitepress │ ├── config.mts │ └── theme │ │ ├── custom.css │ │ └── index.js ├── about.md ├── api │ ├── clients │ │ ├── elixir.md │ │ └── typescript.md │ ├── connectors │ │ ├── mobx.md │ │ ├── react.md │ │ ├── redis.md │ │ └── tanstack.md │ └── http.md ├── electric-api.yaml ├── examples │ ├── basic.md │ └── linearlite.md ├── guides │ ├── deployment.md │ ├── quickstart.md │ ├── shapes.md │ ├── usage.md │ └── write-your-own-client.md ├── index.md ├── package-lock.json ├── package.json ├── product │ ├── ddn.md │ ├── electric.md │ └── pglite.md └── public │ ├── docker-compose.yaml │ ├── favicon.ico │ ├── img │ ├── about │ │ ├── data-flow.jpg │ │ ├── in-and-out-of-scope.jpg │ │ ├── in-and-out-of-scope.png │ │ ├── schema-evolution.jpg │ │ ├── use-cases.jpg │ │ ├── use-cases.png │ │ └── use-cases.sm.png │ ├── api │ │ ├── shape-log.jpg │ │ ├── shape-log.png │ │ └── shape-log.sm.png │ ├── brand │ │ ├── favicon.svg │ │ └── logo.svg │ ├── home │ │ └── zap-with-halo.svg │ └── icons │ │ ├── ddn.svg │ │ ├── electric.svg │ │ └── pglite.svg │ └── robots.txt ├── examples ├── bash-client │ ├── bash-client.bash │ └── package.json ├── basic-example │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── db │ │ └── migrations │ │ │ └── 01-create_items_table.sql │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── robots.txt │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Example.css │ │ ├── Example.tsx │ │ ├── assets │ │ │ └── logo.svg │ │ ├── main.tsx │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── linearlite │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── db │ │ ├── generate_data.js │ │ ├── load_data.js │ │ └── migrations │ │ │ └── 01-create_tables.sql │ ├── index.html │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── electric-icon.png │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── netlify.toml │ │ └── robots.txt │ ├── src │ │ ├── App.tsx │ │ ├── assets │ │ │ ├── fonts │ │ │ │ ├── 27237475-28043385 │ │ │ │ ├── Inter-UI-ExtraBold.woff │ │ │ │ ├── Inter-UI-ExtraBold.woff2 │ │ │ │ ├── Inter-UI-Medium.woff │ │ │ │ ├── Inter-UI-Medium.woff2 │ │ │ │ ├── Inter-UI-Regular.woff │ │ │ │ ├── Inter-UI-Regular.woff2 │ │ │ │ ├── Inter-UI-SemiBold.woff │ │ │ │ └── Inter-UI-SemiBold.woff2 │ │ │ ├── icons │ │ │ │ ├── add-subissue.svg │ │ │ │ ├── add.svg │ │ │ │ ├── archive.svg │ │ │ │ ├── assignee.svg │ │ │ │ ├── attachment.svg │ │ │ │ ├── avatar.svg │ │ │ │ ├── cancel.svg │ │ │ │ ├── chat.svg │ │ │ │ ├── circle-dot.svg │ │ │ │ ├── circle.svg │ │ │ │ ├── claim.svg │ │ │ │ ├── close.svg │ │ │ │ ├── delete.svg │ │ │ │ ├── done.svg │ │ │ │ ├── dots.svg │ │ │ │ ├── due-date.svg │ │ │ │ ├── dupplication.svg │ │ │ │ ├── filter.svg │ │ │ │ ├── git-issue.svg │ │ │ │ ├── guide.svg │ │ │ │ ├── half-circle.svg │ │ │ │ ├── help.svg │ │ │ │ ├── inbox.svg │ │ │ │ ├── issue.svg │ │ │ │ ├── label.svg │ │ │ │ ├── menu.svg │ │ │ │ ├── parent-issue.svg │ │ │ │ ├── plus.svg │ │ │ │ ├── project.svg │ │ │ │ ├── question.svg │ │ │ │ ├── relationship.svg │ │ │ │ ├── rounded-claim.svg │ │ │ │ ├── search.svg │ │ │ │ ├── signal-medium.svg │ │ │ │ ├── signal-strong.svg │ │ │ │ ├── signal-strong.xsd │ │ │ │ ├── signal-weak.svg │ │ │ │ ├── slack.svg │ │ │ │ ├── view.svg │ │ │ │ └── zoom.svg │ │ │ └── images │ │ │ │ ├── icon.inverse.svg │ │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── AboutModal.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── IssueModal.tsx │ │ │ ├── ItemGroup.tsx │ │ │ ├── LeftMenu.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Portal.tsx │ │ │ ├── PriorityIcon.tsx │ │ │ ├── ProfileMenu.tsx │ │ │ ├── Select.tsx │ │ │ ├── StatusIcon.tsx │ │ │ ├── Toggle.tsx │ │ │ ├── TopFilter.tsx │ │ │ ├── ViewOptionMenu.tsx │ │ │ ├── contextmenu │ │ │ │ ├── FilterMenu.tsx │ │ │ │ ├── PriorityMenu.tsx │ │ │ │ ├── StatusMenu.tsx │ │ │ │ └── menu.tsx │ │ │ └── editor │ │ │ │ ├── Editor.tsx │ │ │ │ └── EditorMenu.tsx │ │ ├── electric.tsx │ │ ├── hooks │ │ │ ├── useClickOutside.ts │ │ │ └── useLockBodyScroll.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── Board │ │ │ │ ├── IssueBoard.tsx │ │ │ │ ├── IssueCol.tsx │ │ │ │ ├── IssueItem.tsx │ │ │ │ └── index.tsx │ │ │ ├── Issue │ │ │ │ ├── Comments.tsx │ │ │ │ ├── DeleteModal.tsx │ │ │ │ └── index.tsx │ │ │ ├── List │ │ │ │ ├── IssueList.tsx │ │ │ │ ├── IssueRow.tsx │ │ │ │ └── index.tsx │ │ │ └── root.tsx │ │ ├── shapes.ts │ │ ├── shims │ │ │ └── react-contextmenu.d.ts │ │ ├── style.css │ │ ├── types │ │ │ └── types.ts │ │ ├── utils │ │ │ ├── date.ts │ │ │ ├── filterState.ts │ │ │ └── notification.tsx │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── nextjs-example │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── app │ │ ├── App.css │ │ ├── Example.css │ │ ├── api │ │ │ └── items │ │ │ │ └── route.ts │ │ ├── db.ts │ │ ├── layout.tsx │ │ ├── match-stream.ts │ │ ├── page.tsx │ │ ├── providers.tsx │ │ ├── shape-proxy │ │ │ └── [...table] │ │ │ │ └── route.ts │ │ └── style.css │ ├── db │ │ └── migrations │ │ │ └── 01-create_items_table.sql │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── logo.svg │ │ └── robots.txt │ ├── tsconfig.json │ └── tsconfig.tsbuildinfo ├── redis-client │ ├── .eslintrc.js │ ├── .prettierignore │ ├── .prettierrc │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── remix-basic │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── app │ │ ├── App.css │ │ ├── Example.css │ │ ├── db.ts │ │ ├── match-stream.ts │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── _index.tsx │ │ │ ├── api.items.ts │ │ │ └── shape-proxy.$table.ts │ │ └── style.css │ ├── db │ │ └── migrations │ │ │ └── 01-create_items_table.sql │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── logo.svg │ │ └── robots.txt │ ├── tsconfig.json │ └── vite.config.ts └── todo-app │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierrc │ ├── LICENSE │ ├── README.md │ ├── db │ └── migrations │ │ └── 001-create-todos.sql │ ├── index.html │ ├── package.json │ ├── public │ ├── merriweather.css │ └── typography.css │ ├── server.js │ ├── src │ ├── error-page.tsx │ ├── main.tsx │ └── routes │ │ ├── index.tsx │ │ └── root.tsx │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── integration-tests ├── Makefile ├── README.md ├── electric_dev.sh ├── run.sh └── tests │ ├── invalidated-replication-slot.lux │ └── replication-slot-invalidation.luxinc ├── package.json ├── packages ├── react-hooks │ ├── .eslintrc.cjs │ ├── .prettierignore │ ├── .prettierrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── react-hooks.tsx │ ├── test │ │ ├── react-hooks.test.tsx │ │ └── support │ │ │ ├── global-setup.ts │ │ │ ├── test-context.ts │ │ │ └── test-helpers.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts ├── sync-service │ ├── .env.dev │ ├── .formatter.exs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── Dockerfile │ ├── README.md │ ├── config │ │ └── runtime.exs │ ├── coveralls.json │ ├── dev │ │ ├── docker-compose.yml │ │ ├── init.sql │ │ ├── nginx.conf │ │ └── postgres.conf │ ├── lib │ │ ├── electric.ex │ │ ├── electric │ │ │ ├── application.ex │ │ │ ├── config.ex │ │ │ ├── connection_manager.ex │ │ │ ├── plug │ │ │ │ ├── delete_shape_plug.ex │ │ │ │ ├── label_process_plug.ex │ │ │ │ ├── router.ex │ │ │ │ └── serve_shape_plug.ex │ │ │ ├── postgres.ex │ │ │ ├── postgres │ │ │ │ ├── configuration.ex │ │ │ │ ├── inspector.ex │ │ │ │ ├── inspector │ │ │ │ │ ├── direct_inspector.ex │ │ │ │ │ └── ets_inspector.ex │ │ │ │ ├── logical_replication │ │ │ │ │ ├── decoder.ex │ │ │ │ │ └── messages.ex │ │ │ │ ├── lsn.ex │ │ │ │ ├── replication_client.ex │ │ │ │ └── replication_client │ │ │ │ │ ├── collector.ex │ │ │ │ │ └── connection_setup.ex │ │ │ ├── replication │ │ │ │ ├── changes.ex │ │ │ │ ├── eval │ │ │ │ │ ├── env.ex │ │ │ │ │ ├── env │ │ │ │ │ │ ├── basic_types.ex │ │ │ │ │ │ ├── explicit_casts.ex │ │ │ │ │ │ ├── implicit_casts.ex │ │ │ │ │ │ └── known_functions.ex │ │ │ │ │ ├── expr.ex │ │ │ │ │ ├── known_definition.ex │ │ │ │ │ ├── lookups.ex │ │ │ │ │ ├── parser.ex │ │ │ │ │ └── runner.ex │ │ │ │ ├── log_offset.ex │ │ │ │ ├── postgres_interop │ │ │ │ │ └── casting.ex │ │ │ │ └── shape_log_collector.ex │ │ │ ├── schema.ex │ │ │ ├── shape_cache.ex │ │ │ ├── shape_cache │ │ │ │ ├── cub_db_storage.ex │ │ │ │ ├── in_memory_storage.ex │ │ │ │ └── storage.ex │ │ │ ├── shapes.ex │ │ │ ├── shapes │ │ │ │ ├── querying.ex │ │ │ │ └── shape.ex │ │ │ ├── telemetry.ex │ │ │ └── utils.ex │ │ └── pg_interop │ │ │ ├── interval.ex │ │ │ ├── interval │ │ │ ├── iso8601_alternative_parser.ex │ │ │ ├── iso8601_formatter.ex │ │ │ ├── iso8601_parser.ex │ │ │ └── postgres_and_sql_parser.ex │ │ │ └── postgrex │ │ │ ├── extensions │ │ │ └── pg_lsn.ex │ │ │ └── types.ex │ ├── mix.exs │ ├── mix.lock │ ├── package.json │ ├── rel │ │ ├── env.sh.eex │ │ └── vm.args.eex │ └── test │ │ ├── electric │ │ ├── config_test.exs │ │ ├── plug │ │ │ ├── delete_shape_plug_test.exs │ │ │ ├── label_process_plug_test.exs │ │ │ ├── router_test.exs │ │ │ └── serve_shape_plug_test.exs │ │ ├── postgres │ │ │ ├── configuration_test.exs │ │ │ ├── decoder_test.exs │ │ │ ├── inspector │ │ │ │ └── ets_inspector_test.exs │ │ │ ├── lsn_test.exs │ │ │ ├── replication_client │ │ │ │ └── collector_test.exs │ │ │ └── replication_client_test.exs │ │ ├── postgres_test.exs │ │ ├── replication │ │ │ ├── changes_test.exs │ │ │ ├── eval │ │ │ │ ├── env │ │ │ │ │ └── basic_types_test.exs │ │ │ │ ├── parser_test.exs │ │ │ │ └── runner_test.exs │ │ │ ├── log_offset_test.exs │ │ │ ├── postgres_interop │ │ │ │ └── casting_test.exs │ │ │ └── shape_log_collector_test.exs │ │ ├── schema_test.exs │ │ ├── shape_cache │ │ │ ├── storage_implementations_test.exs │ │ │ └── storage_test.exs │ │ ├── shape_cache_test.exs │ │ ├── shapes │ │ │ ├── querying_test.exs │ │ │ └── shape_test.exs │ │ └── utils_test.exs │ │ ├── pg_interop │ │ ├── interval │ │ │ ├── iso8601_alternative_parser_test.exs │ │ │ ├── iso8601_formatter_test.exs │ │ │ ├── iso8601_parser_test.exs │ │ │ └── postgres_and_sql_parser_test.exs │ │ ├── interval_test.exs │ │ └── postgrex │ │ │ └── extensions │ │ │ └── pg_lsn_test.exs │ │ ├── support │ │ ├── component_setup.ex │ │ ├── db_setup.ex │ │ ├── db_structure_setup.ex │ │ ├── stub_inspector.ex │ │ ├── test_utils.ex │ │ └── transaction_case.ex │ │ └── test_helper.exs └── typescript-client │ ├── .eslintrc.cjs │ ├── .prettierignore │ ├── .prettierrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── client.ts │ ├── index.ts │ ├── parser.ts │ └── types.ts │ ├── test │ ├── cache.test.ts │ ├── client.test.ts │ ├── integration.test.ts │ ├── parser.test.ts │ └── support │ │ ├── global-setup.ts │ │ ├── test-context.ts │ │ └── test-helpers.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.base.json └── tsconfig.build.json /.buildkite/docker-image.yml: -------------------------------------------------------------------------------- 1 | env: 2 | DOCKERHUB_REPO: electricsql 3 | IMAGE_NAME: electric-next 4 | 5 | agent: 6 | docker: true 7 | gcp: true 8 | 9 | steps: 10 | - label: ":rocket: Publish the image to DockerHub" 11 | if: build.tag =~ /@core\/sync-service@/ 12 | command: 13 | - export ELECTRIC_IMAGE_NAME="${DOCKERHUB_REPO}/${IMAGE_NAME}" 14 | - cd ./packages/sync-service 15 | - export ELECTRIC_VERSION=$(jq '.version' -r package.json) 16 | - docker buildx build --platform linux/arm64/v8,linux/amd64 --push 17 | --build-arg ELECTRIC_VERSION=$${ELECTRIC_VERSION} 18 | -t $${ELECTRIC_IMAGE_NAME}:$${ELECTRIC_VERSION} 19 | -t $${ELECTRIC_IMAGE_NAME}:latest 20 | . 21 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["@electric-examples/*"], 11 | "privatePackages": { "tag": true, "version": true } 12 | } 13 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:password@localhost:54321/electric 2 | -------------------------------------------------------------------------------- /.github/workflows/changesets_release.yml: -------------------------------------------------------------------------------- 1 | name: Changesets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | changesets: 12 | name: Make a PR or publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | ref: ${{ github.event.pull_request.head.sha }} 18 | fetch-depth: 0 19 | - uses: pnpm/action-setup@v4 20 | with: 21 | version: 9 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 25 | cache: pnpm 26 | - run: pnpm install --frozen-lockfile 27 | - run: pnpm -r build 28 | - name: Create Release Pull Request or Publish 29 | id: changesets 30 | uses: changesets/action@v1 31 | with: 32 | version: pnpm ci:version 33 | publish: pnpm ci:publish 34 | title: 'chore: publish new package versions' 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/integration_tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | working-directory: integration-tests 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: erlef/setup-beam@v1 21 | with: 22 | version-type: strict 23 | version-file: '.tool-versions' 24 | - name: Restore dependencies cache 25 | uses: actions/cache@v3 26 | with: 27 | path: packages/sync-service/deps 28 | key: ${{ runner.os }}-mix-${{ hashFiles('packages/sync-service/mix.lock') }} 29 | restore-keys: ${{ runner.os }}-mix- 30 | - name: Restore compiled code 31 | uses: actions/cache/restore@v4 32 | with: 33 | path: | 34 | packages/sync-service/_build/*/lib 35 | !packages/sync-service/_build/*/lib/electric 36 | key: ${{ runner.os }}-build-test-${{ hashFiles('packages/sync-service/mix.lock') }} 37 | - name: Install dependencies 38 | run: mix deps.get && mix deps.compile 39 | working-directory: packages/sync-service 40 | - name: Save compiled code 41 | uses: actions/cache/save@v4 42 | with: 43 | path: | 44 | packages/sync-service/_build/*/lib 45 | !packages/sync-service/_build/*/lib/electric 46 | key: ${{ runner.os }}-build-test-${{ hashFiles('packages/sync-service/mix.lock') }} 47 | - name: Compile 48 | run: mix compile --force --all-warnings --warnings-as-errors 49 | working-directory: packages/sync-service 50 | - name: Setup lux 51 | run: make 52 | - name: Run integration tests 53 | run: ./run.sh 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/.vitepress/dist 2 | docs/.vitepress/cache 3 | docs/public/openapi.html 4 | integration-tests/lux/ 5 | integration-tests/lux_logs/ 6 | json_files 7 | file.jsonl 8 | node_modules 9 | wal 10 | test-dbs 11 | shape-data.json 12 | caching/nginx_cache 13 | .vscode 14 | .DS_store 15 | **/dist/** 16 | build 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /.support/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | name: "electric_example-${PROJECT_NAME:-default}" 3 | 4 | services: 5 | postgres: 6 | image: postgres:16-alpine 7 | environment: 8 | POSTGRES_DB: electric 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | ports: 12 | - 54321:5432 13 | volumes: 14 | - ./postgres.conf:/etc/postgresql/postgresql.conf:ro 15 | tmpfs: 16 | - /var/lib/postgresql/data 17 | - /tmp 18 | command: 19 | - postgres 20 | - -c 21 | - config_file=/etc/postgresql/postgresql.conf 22 | 23 | backend: 24 | image: electricsql/electric-next:example 25 | environment: 26 | DATABASE_URL: postgresql://postgres:password@postgres:5432/electric 27 | ports: 28 | - 3000:3000 29 | build: 30 | context: ../packages/sync-service/ 31 | depends_on: 32 | - postgres 33 | -------------------------------------------------------------------------------- /.support/postgres.conf: -------------------------------------------------------------------------------- 1 | listen_addresses = '*' 2 | wal_level = logical # minimal, replica, or logical 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.2-otp-27 2 | erlang 27.0.1 3 | nodejs 20.2.0 4 | pnpm 9.4.0 -------------------------------------------------------------------------------- /LIMITATIONS.md: -------------------------------------------------------------------------------- 1 | # Limitations 2 | 3 | - Electric doesn't handle TRUNCATE messages. 4 | - First implementation is to invalidate the shapes that are possibly affected by TRUNCATE. 5 | - No transaction boundaries in shape logs 6 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | // .vitepress/theme/index.js 2 | import DefaultTheme from 'vitepress/theme-without-fonts' 3 | import './custom.css' 4 | 5 | export default DefaultTheme -------------------------------------------------------------------------------- /docs/api/clients/elixir.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Elixir Client 6 | 7 | The Elixir client is being developed in [electric-sql/electric-next/pull/38](https://github.com/electric-sql/electric-next/pull/38). At the moment it provides a GenStage producer that can be used to stream a Shape as per: 8 | 9 | ```elixir 10 | opts = [ 11 | base_url: "http://...", 12 | shape_definition: %Electric.Client.ShapeDefinition{ 13 | table: "..." 14 | } 15 | ] 16 | 17 | {:ok, pid, stream} = Electric.Client.ShapeStream.stream(opts) 18 | 19 | stream 20 | |> Stream.each(&IO.inspect/1) 21 | |> Stream.run() 22 | ``` 23 | 24 | See the [shape_stream_test.exs](https://github.com/electric-sql/electric-next/blob/thruflo/elixir-client/elixir_client/test/electric/client/shape_stream_test.exs) for more details. 25 | 26 | -------------------------------------------------------------------------------- /docs/api/connectors/mobx.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/api/connectors/mobx.md -------------------------------------------------------------------------------- /docs/api/connectors/react.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/api/connectors/react.md -------------------------------------------------------------------------------- /docs/api/connectors/redis.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/api/connectors/redis.md -------------------------------------------------------------------------------- /docs/api/connectors/tanstack.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/api/connectors/tanstack.md -------------------------------------------------------------------------------- /docs/examples/basic.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/examples/basic.md -------------------------------------------------------------------------------- /docs/examples/linearlite.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/examples/linearlite.md -------------------------------------------------------------------------------- /docs/guides/deployment.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Deployment 6 | 7 | This page is under construction. -------------------------------------------------------------------------------- /docs/guides/shapes.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Shapes 6 | 7 | This page is under construction. -------------------------------------------------------------------------------- /docs/guides/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Usage guide 6 | 7 | See the [Quickstart](./quickstart) and [Examples](../examples/basic). -------------------------------------------------------------------------------- /docs/guides/write-your-own-client.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Write your own client 6 | 7 | This page is under construction. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | title: 'Postgres Everywhere' 5 | description: 'Your data, in sync, wherever you need it.' 6 | hero: 7 | name: "Postgres" 8 | text: "everywhere" 9 | tagline: >- 10 | Your data, in sync, wherever you need it. 11 | image: 12 | src: /img/home/zap-with-halo.svg 13 | actions: 14 | - theme: brand 15 | text: Quickstart 16 | link: /guides/quickstart 17 | - theme: alt 18 | text: About 19 | link: /about 20 | features: 21 | - title: Sync Engine 22 | details: >- 23 | Sync partial replicas of your data into local 24 | apps and services. 25 | icon: 26 | src: '/img/icons/electric.svg' 27 | link: '/product/electric' 28 | - title: Data Delivery Network 29 | details: >- 30 | Load data faster than you can query Postgres, 31 | scale out to millions of users. 32 | icon: 33 | src: '/img/icons/ddn.svg' 34 | link: '/product/ddn' 35 | - title: PGlite 36 | details: >- 37 | Embed a lightweight client Postgres with 38 | real-time, reactive bindings. 39 | icon: 40 | src: '/img/icons/pglite.svg' 41 | link: '/product/pglite' 42 | --- 43 | 44 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-sql/docs", 3 | "scripts": { 4 | "api:generate": "redocly build-docs ./electric-api.yaml --output=./public/openapi.html", 5 | "api:watch": "nodemon -w ./ -x \"npm run api:generate\" -e \"*.yaml\"", 6 | "docs:build": "npm run api:generate && vitepress build .", 7 | "docs:dev": "vitepress dev .", 8 | "docs:preview": "vitepress preview ." 9 | }, 10 | "devDependencies": { 11 | "@redocly/cli": "^1.18.0", 12 | "nodemon": "^3.1.4", 13 | "vitepress": "^1.3.1", 14 | "vue-tweet": "^2.3.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/product/ddn.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | 6 | 7 | # Data Delivery Network 8 | 9 | Load data faster than you can query Postgres, 10 | scale out to millions of users. 11 | 12 | ## Use cases 13 | 14 | This allows you to scale out real-time data to millions of concurrent users from a single commodity Postgres. With blazing fast load times, minimal latency and low resource use. 15 | 16 | 17 | 18 | ## How does it work? 19 | 20 | `electric-next` has been designed from the ground up to deliver fast initial data loads and low latency ongoing sync. It exposes this through an [HTTP API](/api/http) that provides standard caching headers that work out-of-the-box with CDNs like Cloudflare and Fastly. 21 | 22 | ## How do I use it? 23 | 24 | 25 | 26 | Run Electric and put it behind a CDN. 27 | 28 | We will add more detailed guide and example content. For now, see the [sync-service/dev/nginx.conf](https://github.com/electric-sql/electric-next/blob/main/packages/sync-service/dev/nginx.conf) and [typescript-client/test/cache.test.ts](https://github.com/electric-sql/electric-next/blob/main/packages/typescript-client/test/cache.test.ts) for example usage. 29 | -------------------------------------------------------------------------------- /docs/product/electric.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | 6 | 7 | # Electric Sync Engine 8 | 9 | Sync partial replicas of your data into local 10 | apps and services. 11 | 12 | ## Use cases 13 | 14 | The Electric Sync Engine syncs subsets of data out of Postgres into local apps, services and environments — wherever you need the data. 15 | 16 | Use cases diagramme 21 | 22 | You can sync data into: 23 | 24 | - web and mobile apps, [replacing data fetching with data sync](/examples/linearlite) 25 | - development environments, for example syncing data into [an embedded PGlite](/product/pglite) 26 | - edge workers and services, for example maintaining a low-latency [edge data cache](/api/connectors/redis) 27 | - local AI systems running RAG, for example [using pgvector](https://electric-sql.com/blog/2024/02/05/local-first-ai-with-tauri-postgres-pgvector-llama) 28 | - databases like [PGlite](./pglite) 29 | 30 | ## How does it work? 31 | 32 | The Electric sync engine is an [Elixir](https://elixir-lang.org) application, developed at [electric-sql/electric-next/tree/main/packages/sync-service](https://github.com/electric-sql/electric-next/tree/main/packages/sync-service). 33 | 34 | It connects to your Postgres using a `DATABASE_URL`, consumes the logical replication stream and provides [an HTTP API](/api/http) for replicating [Shapes](/guides/shapes). 35 | 36 | ## How do I use it? 37 | 38 | See the [Quickstart](/guides/quickstart) and [Examples](/examples/basic). 39 | -------------------------------------------------------------------------------- /docs/product/pglite.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | 6 | 7 | # PGlite 8 | 9 | Embed a lightweight client Postgres with 10 | real-time, reactive bindings. 11 | 12 | ## Use cases 13 | 14 | PGlite is a lightweight WASM Postgres build, packaged into a TypeScript library for the browser, Node.js, Bun and Deno. 15 | 16 | PGlite repl screenshot 19 | 20 | PGlite allows you to run Postgres in the browser, Node.js and Bun, with no need to install any other dependencies. It is only 2.6mb gzipped. 21 | 22 | ```js 23 | import { PGlite } from "@electric-sql/pglite"; 24 | 25 | const db = new PGlite(); 26 | await db.query("select 'Hello world' as message;"); 27 | // -> { rows: [ { message: "Hello world" } ] } 28 | ``` 29 | 30 | It can be used as an ephemeral in-memory database, or with persistence either to the file system (Node/Bun) or indexedDB (Browser). 31 | 32 | ## How does it work? 33 | 34 | Unlike previous "Postgres in the browser" projects, PGlite does not use a Linux virtual machine - it is simply Postgres in WASM, compiled using [Emscripten](https://en.wikipedia.org/wiki/Emscripten). It provides a mechanism for dynamic extension loading, debug tooling and an in-browser repl. 35 | 36 | ## How do I use it? 37 | 38 | See the [electric-sql/pglite](https://github.com/electric-sql/pglite) repo for more details. 39 | -------------------------------------------------------------------------------- /docs/public/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | name: "electric_quickstart" 3 | 4 | services: 5 | postgres: 6 | image: postgres:16-alpine 7 | environment: 8 | POSTGRES_DB: electric 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | ports: 12 | - 54321:5432 13 | tmpfs: 14 | - /var/lib/postgresql/data 15 | - /tmp 16 | command: 17 | - -c 18 | - listen_addresses=* 19 | - -c 20 | - wal_level=logical 21 | 22 | electric: 23 | image: electricsql/electric-next 24 | environment: 25 | DATABASE_URL: postgresql://postgres:password@postgres:5432/electric 26 | ports: 27 | - "3000:3000" 28 | depends_on: 29 | - postgres 30 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/img/about/data-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/data-flow.jpg -------------------------------------------------------------------------------- /docs/public/img/about/in-and-out-of-scope.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/in-and-out-of-scope.jpg -------------------------------------------------------------------------------- /docs/public/img/about/in-and-out-of-scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/in-and-out-of-scope.png -------------------------------------------------------------------------------- /docs/public/img/about/schema-evolution.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/schema-evolution.jpg -------------------------------------------------------------------------------- /docs/public/img/about/use-cases.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/use-cases.jpg -------------------------------------------------------------------------------- /docs/public/img/about/use-cases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/use-cases.png -------------------------------------------------------------------------------- /docs/public/img/about/use-cases.sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/about/use-cases.sm.png -------------------------------------------------------------------------------- /docs/public/img/api/shape-log.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/api/shape-log.jpg -------------------------------------------------------------------------------- /docs/public/img/api/shape-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/api/shape-log.png -------------------------------------------------------------------------------- /docs/public/img/api/shape-log.sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/docs/public/img/api/shape-log.sm.png -------------------------------------------------------------------------------- /docs/public/img/brand/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/public/img/icons/electric.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/public/img/icons/pglite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /examples/bash-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-examples/bash-client", 3 | "version": "0.0.1", 4 | "private": true, 5 | "author": "ElectricSQL", 6 | "scripts": { 7 | "backend:up": "PROJECT_NAME=bash-client pnpm -C ../../ run example-backend:up && pnpm db:migrate", 8 | "backend:down": "PROJECT_NAME=bash-client pnpm -C ../../ run example-backend:down", 9 | "build": "echo 'Nothing to build'", 10 | "typecheck": "echo 'Nothing to typecheck'" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/basic-example/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env.local 3 | -------------------------------------------------------------------------------- /examples/basic-example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic-example/README.md: -------------------------------------------------------------------------------- 1 | # Basic example 2 | 3 | ## Setup 4 | 5 | 1. Make sure you've installed all dependencies for the monorepo and built packages 6 | 7 | From the root directory: 8 | 9 | - `pnpm i` 10 | - `pnpm run -r build` 11 | 12 | 2. Start the docker containers 13 | 14 | `pnpm run backend:up` 15 | 16 | 3. Start the dev server 17 | 18 | `pnpm run dev` 19 | 20 | 4. When done, tear down the backend containers so you can run other examples 21 | 22 | `pnpm run backend:down` 23 | -------------------------------------------------------------------------------- /examples/basic-example/db/migrations/01-create_items_table.sql: -------------------------------------------------------------------------------- 1 | -- Create a simple items table. 2 | CREATE TABLE IF NOT EXISTS items ( 3 | id TEXT PRIMARY KEY NOT NULL 4 | ); 5 | 6 | -- Populate the table with 10 items. 7 | -- FIXME: Remove this once writing out of band is implemented 8 | WITH generate_series AS ( 9 | SELECT gen_random_uuid()::text AS id 10 | FROM generate_series(1, 10) 11 | ) 12 | INSERT INTO items (id) 13 | SELECT id 14 | FROM generate_series; 15 | -------------------------------------------------------------------------------- /examples/basic-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Web Example - ElectricSQL 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/basic-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-examples/basic-example", 3 | "private": true, 4 | "version": "0.0.1", 5 | "author": "ElectricSQL", 6 | "license": "Apache-2.0", 7 | "type": "module", 8 | "scripts": { 9 | "backend:up": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:up && pnpm db:migrate", 10 | "backend:down": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:down", 11 | "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations", 12 | "dev": "vite", 13 | "build": "vite build", 14 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 15 | "preview": "vite preview", 16 | "typecheck": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "@electric-sql/react": "workspace:*", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1" 22 | }, 23 | "devDependencies": { 24 | "@databases/pg-migrations": "^5.0.3", 25 | "@types/react": "^18.3.3", 26 | "@types/react-dom": "^18.3.0", 27 | "@vitejs/plugin-react": "^4.3.1", 28 | "dotenv": "^16.4.5", 29 | "eslint": "^8.57.0", 30 | "typescript": "^5.5.3", 31 | "vite": "^5.3.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/basic-example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/basic-example/public/favicon.ico -------------------------------------------------------------------------------- /examples/basic-example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/basic-example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: min(160px, 30vmin); 7 | pointer-events: none; 8 | margin-top: min(30px, 5vmin); 9 | margin-bottom: min(30px, 5vmin); 10 | } 11 | 12 | .App-header { 13 | background-color: #1c1e20; 14 | min-height: 100vh; 15 | display: flex; 16 | flex-direction: column; 17 | align-items: top; 18 | justify-content: top; 19 | font-size: calc(10px + 2vmin); 20 | color: white; 21 | } 22 | 23 | .App-link { 24 | color: #61dafb; 25 | } 26 | -------------------------------------------------------------------------------- /examples/basic-example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import logo from './assets/logo.svg' 2 | import './App.css' 3 | import './style.css' 4 | 5 | import { Example } from './Example' 6 | import { ShapesProvider } from '@electric-sql/react' 7 | 8 | export default function App() { 9 | return ( 10 |
11 |
12 | logo 13 | 14 | 15 | 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /examples/basic-example/src/Example.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | margin-bottom: 1.5rem; 3 | } 4 | 5 | .button { 6 | display: inline-block; 7 | line-height: 1.3; 8 | text-align: center; 9 | text-decoration: none; 10 | vertical-align: middle; 11 | cursor: pointer; 12 | user-select: none; 13 | width: calc(15vw + 100px); 14 | margin-right: 0.5rem !important; 15 | margin-left: 0.5rem !important; 16 | border-radius: 32px; 17 | text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4); 18 | box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px; 19 | background: #1e2123; 20 | border: 2px solid #229089; 21 | color: #f9fdff; 22 | font-size: 16px; 23 | font-weight: 500; 24 | padding: 10px 18px; 25 | } 26 | 27 | .item { 28 | display: block; 29 | line-height: 1.3; 30 | text-align: center; 31 | vertical-align: middle; 32 | width: calc(30vw - 1.5rem + 200px); 33 | margin-right: auto; 34 | margin-left: auto; 35 | border-radius: 32px; 36 | border: 1.5px solid #bbb; 37 | box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px; 38 | color: #f9fdff; 39 | font-size: 13px; 40 | padding: 10px 18px; 41 | } 42 | -------------------------------------------------------------------------------- /examples/basic-example/src/Example.tsx: -------------------------------------------------------------------------------- 1 | import { useShape } from '@electric-sql/react' 2 | import './Example.css' 3 | 4 | type Item = { id: string } 5 | 6 | const baseUrl = import.meta.env.ELECTRIC_URL ?? `http://localhost:3000` 7 | 8 | export const Example = () => { 9 | const { data: items } = useShape({ 10 | url: `${baseUrl}/v1/shape/items`, 11 | }) as unknown as { data: Item[] } 12 | 13 | /* 14 | const addItem = async () => { 15 | console.log(`'addItem' is not implemented`) 16 | } 17 | 18 | const clearItems = async () => { 19 | console.log(`'clearItems' is not implemented`) 20 | } 21 | 22 |
23 | 26 | 29 |
30 | */ 31 | return ( 32 |
33 | {items.map((item: Item, index: number) => ( 34 |

35 | {item.id} 36 |

37 | ))} 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /examples/basic-example/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic-example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './style.css' 5 | 6 | ReactDOM.createRoot(document.getElementById(`root`)!).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /examples/basic-example/src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Helvetica Neue', Helvetica, sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | background: #1c1e20; 7 | min-width: 360px; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/basic-example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/basic-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/basic-example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /examples/linearlite/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | 'no-unused-vars': `off`, 25 | '@typescript-eslint/no-unused-vars': [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | ignorePatterns: [ 35 | '**/node_modules/**', 36 | '**/dist/**', 37 | 'tsup.config.ts', 38 | 'vitest.config.ts', 39 | '.eslintrc.js' 40 | ], 41 | } 42 | -------------------------------------------------------------------------------- /examples/linearlite/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env.local 3 | db/data/ -------------------------------------------------------------------------------- /examples/linearlite/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /examples/linearlite/README.md: -------------------------------------------------------------------------------- 1 | # Linearlite 2 | 3 | ## Setup 4 | 5 | 1. Make sure you've installed all dependencies for the monorepo and built packages 6 | 7 | From the root directory: 8 | 9 | - `pnpm i` 10 | - `pnpm run -r build` 11 | 12 | 2. Start the docker containers 13 | 14 | `pnpm run backend:up` 15 | 16 | 3. Start the dev server 17 | 18 | `pnpm run dev` 19 | 20 | 4. When done, tear down the backend containers so you can run other examples 21 | 22 | `pnpm run backend:down` 23 | -------------------------------------------------------------------------------- /examples/linearlite/db/generate_data.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { generateNKeysBetween } from 'fractional-indexing' 3 | import { v4 as uuidv4 } from 'uuid' 4 | 5 | export function generateIssues(numIssues) { 6 | // generate properly spaced kanban keys and shuffle them 7 | const kanbanKeys = faker.helpers.shuffle( 8 | generateNKeysBetween(null, null, numIssues) 9 | ) 10 | return Array.from({ length: numIssues }, (_, idx) => 11 | generateIssue(kanbanKeys[idx]) 12 | ) 13 | } 14 | 15 | function generateIssue(kanbanKey) { 16 | const issueId = uuidv4() 17 | const createdAt = faker.date.past() 18 | return { 19 | id: issueId, 20 | title: faker.lorem.sentence({ min: 3, max: 8 }), 21 | description: faker.lorem.sentences({ min: 2, max: 6 }, `\n`), 22 | priority: faker.helpers.arrayElement([`none`, `low`, `medium`, `high`]), 23 | status: faker.helpers.arrayElement([ 24 | `backlog`, 25 | `todo`, 26 | `in_progress`, 27 | `done`, 28 | `canceled`, 29 | ]), 30 | created: createdAt.toISOString(), 31 | modified: faker.date 32 | .between({ from: createdAt, to: new Date() }) 33 | .toISOString(), 34 | kanbanorder: kanbanKey, 35 | username: faker.internet.userName(), 36 | comments: faker.helpers.multiple( 37 | () => generateComment(issueId, createdAt), 38 | { count: faker.number.int({ min: 0, max: 10 }) } 39 | ), 40 | } 41 | } 42 | 43 | function generateComment(issueId, issueCreatedAt) { 44 | return { 45 | id: uuidv4(), 46 | body: faker.lorem.text(), 47 | username: faker.internet.userName(), 48 | issue_id: issueId, 49 | created_at: faker.date 50 | .between({ from: issueCreatedAt, to: new Date() }) 51 | .toISOString(), 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/linearlite/db/load_data.js: -------------------------------------------------------------------------------- 1 | import createPool, { sql } from '@databases/pg' 2 | import { generateIssues } from './generate_data.js' 3 | 4 | if (!process.env.DATABASE_URL) { 5 | throw new Error(`DATABASE_URL is not set`) 6 | } 7 | 8 | const DATABASE_URL = process.env.DATABASE_URL 9 | const ISSUES_TO_LOAD = process.env.ISSUES_TO_LOAD || 512 10 | const issues = generateIssues(ISSUES_TO_LOAD) 11 | 12 | console.info(`Connecting to Postgres at ${DATABASE_URL}`) 13 | const db = createPool(DATABASE_URL) 14 | 15 | async function makeInsertQuery(db, table, data) { 16 | const columns = Object.keys(data) 17 | const columnsNames = columns.join(`, `) 18 | const values = columns.map((column) => data[column]) 19 | return await db.query(sql` 20 | INSERT INTO ${sql.ident(table)} (${sql(columnsNames)}) 21 | VALUES (${sql.join(values.map(sql.value), `, `)}) 22 | `) 23 | } 24 | 25 | async function importIssue(db, issue) { 26 | const { comments: _, ...rest } = issue 27 | return await makeInsertQuery(db, `issue`, rest) 28 | } 29 | 30 | async function importComment(db, comment) { 31 | return await makeInsertQuery(db, `comment`, comment) 32 | } 33 | 34 | const issueCount = issues.length 35 | let commentCount = 0 36 | const batchSize = 100 37 | for (let i = 0; i < issueCount; i += batchSize) { 38 | await db.tx(async (db) => { 39 | db.query(sql`SET CONSTRAINTS ALL DEFERRED;`) // disable FK checks 40 | for (let j = i; j < i + batchSize && j < issueCount; j++) { 41 | process.stdout.write(`Loading issue ${j + 1} of ${issueCount}\r`) 42 | const issue = issues[j] 43 | await importIssue(db, issue) 44 | for (const comment of issue.comments) { 45 | commentCount++ 46 | await importComment(db, comment) 47 | } 48 | } 49 | }) 50 | } 51 | 52 | process.stdout.write(`\n`) 53 | 54 | db.dispose() 55 | console.info(`Loaded ${issueCount} issues with ${commentCount} comments.`) 56 | -------------------------------------------------------------------------------- /examples/linearlite/db/migrations/01-create_tables.sql: -------------------------------------------------------------------------------- 1 | -- Create the tables for the linearlite example 2 | CREATE TABLE IF NOT EXISTS "issue" ( 3 | "id" UUID NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "description" TEXT NOT NULL, 6 | "priority" TEXT NOT NULL, 7 | "status" TEXT NOT NULL, 8 | "modified" TIMESTAMPTZ NOT NULL, 9 | "created" TIMESTAMPTZ NOT NULL, 10 | "kanbanorder" TEXT NOT NULL, 11 | "username" TEXT NOT NULL, 12 | CONSTRAINT "issue_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS "comment" ( 16 | "id" UUID NOT NULL, 17 | "body" TEXT NOT NULL, 18 | "username" TEXT NOT NULL, 19 | "issue_id" UUID NOT NULL, 20 | "created_at" TIMESTAMPTZ NOT NULL, 21 | CONSTRAINT "comment_pkey" PRIMARY KEY ("id"), 22 | -- FOREIGN KEY (username) REFERENCES "user"(username), 23 | FOREIGN KEY (issue_id) REFERENCES issue(id) DEFERRABLE 24 | ); -------------------------------------------------------------------------------- /examples/linearlite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LinearLite 8 | 9 | 10 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/linearlite/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /examples/linearlite/public/electric-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/public/electric-icon.png -------------------------------------------------------------------------------- /examples/linearlite/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/public/favicon.ico -------------------------------------------------------------------------------- /examples/linearlite/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/public/logo192.png -------------------------------------------------------------------------------- /examples/linearlite/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/public/logo512.png -------------------------------------------------------------------------------- /examples/linearlite/public/netlify.toml: -------------------------------------------------------------------------------- 1 | 2 | [[redirects]] 3 | from = "/*" 4 | to = "/index.html" 5 | status = 200 6 | -------------------------------------------------------------------------------- /examples/linearlite/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/linearlite/src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'animate.css/animate.min.css' 2 | import Board from './pages/Board' 3 | import { useState, createContext } from 'react' 4 | import { createBrowserRouter, RouterProvider } from 'react-router-dom' 5 | import 'react-toastify/dist/ReactToastify.css' 6 | import List from './pages/List' 7 | import Root from './pages/root' 8 | import Issue from './pages/Issue' 9 | import { ShapesProvider, preloadShape } from '@electric-sql/react' 10 | import { issueShape } from './shapes' 11 | 12 | interface MenuContextInterface { 13 | showMenu: boolean 14 | setShowMenu: (show: boolean) => void 15 | } 16 | 17 | export const MenuContext = createContext(null as MenuContextInterface | null) 18 | 19 | const router = createBrowserRouter([ 20 | { 21 | path: `/`, 22 | element: , 23 | loader: async () => { 24 | console.time(`preload`) 25 | await preloadShape(issueShape) 26 | console.timeEnd(`preload`) 27 | return null 28 | }, 29 | children: [ 30 | { 31 | path: `/`, 32 | element: , 33 | }, 34 | { 35 | path: `/search`, 36 | element: , 37 | }, 38 | { 39 | path: `/board`, 40 | element: , 41 | }, 42 | { 43 | path: `/issue/:id`, 44 | element: , 45 | }, 46 | ], 47 | }, 48 | ]) 49 | 50 | const App = () => { 51 | const [showMenu, setShowMenu] = useState(false) 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | } 61 | 62 | export default App 63 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/27237475-28043385: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/27237475-28043385 -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff2 -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff2 -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff -------------------------------------------------------------------------------- /examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff2 -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/add-subissue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/archive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/assignee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/attachment.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/cancel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/claim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/done.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/due-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/dupplication.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/git-issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/guide.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/half-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/inbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/issue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/label.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/parent-issue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/question.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/relationship.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/rounded-claim.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/signal-medium.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/signal-strong.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/signal-strong.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/signal-weak.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/icons/zoom.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/linearlite/src/assets/images/icon.inverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/AboutModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from './Modal' 2 | 3 | interface Props { 4 | isOpen: boolean 5 | onDismiss?: () => void 6 | } 7 | 8 | export default function AboutModal({ isOpen, onDismiss }: Props) { 9 | return ( 10 | 11 |
12 |

13 | This is an example of a team collaboration app such as{` `} 14 | 15 | Linear 16 | 17 | {` `} 18 | built using{` `} 19 | 20 | ElectricSQL 21 | 22 | {` `}- the local-first sync layer for web and mobile apps. 23 |

24 |

25 | This example is built on top of the excellent clone of the Linear UI 26 | built by{` `} 27 | 28 | Tuan Nguyen 29 | 30 | . 31 |

32 |

33 | We have replaced the canned data with a stack running{` `} 34 | 35 | Electric 36 | 37 | {` `} 38 | in Docker. 39 |

40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/ItemGroup.tsx: -------------------------------------------------------------------------------- 1 | import { BsFillCaretDownFill, BsFillCaretRightFill } from 'react-icons/bs' 2 | import * as React from 'react' 3 | import { useState } from 'react' 4 | 5 | interface Props { 6 | title: string 7 | children: React.ReactNode 8 | } 9 | function ItemGroup({ title, children }: Props) { 10 | const [showItems, setShowItems] = useState(true) 11 | 12 | const Icon = showItems ? BsFillCaretDownFill : BsFillCaretRightFill 13 | return ( 14 |
15 | 22 | {showItems && children} 23 |
24 | ) 25 | } 26 | 27 | export default ItemGroup 28 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from 'react' 2 | import { useEffect } from 'react' 3 | import { createPortal } from 'react-dom' 4 | 5 | //Copied from https://github.com/tailwindlabs/headlessui/blob/71730fea1291e572ae3efda16d8644f870d87750/packages/%40headlessui-react/pages/menu/menu-with-popper.tsx#L90 6 | export function Portal(props: { children: ReactNode }) { 7 | const { children } = props 8 | const [mounted, setMounted] = useState(false) 9 | 10 | useEffect(() => setMounted(true), []) 11 | 12 | if (!mounted) return null 13 | return createPortal(children, document.body) 14 | } 15 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/PriorityIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { PriorityIcons } from '../types/types' 3 | 4 | interface Props { 5 | priority: string 6 | className?: string 7 | } 8 | 9 | export default function PriorityIcon({ priority, className }: Props) { 10 | const classes = classNames(`w-4 h-4`, className) 11 | const Icon = PriorityIcons[priority.toLowerCase()] 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { ReactNode } from 'react' 3 | 4 | interface Props { 5 | className?: string 6 | children: ReactNode 7 | defaultValue?: string | number | ReadonlyArray 8 | value?: string | number | ReadonlyArray 9 | onChange?: (event: React.ChangeEvent) => void 10 | } 11 | export default function Select(props: Props) { 12 | const { children, defaultValue, className, value, onChange, ...rest } = props 13 | 14 | const classes = classNames( 15 | `form-select text-xs focus:ring-transparent form-select text-gray-800 h-6 bg-gray-100 rounded pr-4.5 bg-right pl-2 py-0 appearance-none focus:outline-none border-none`, 16 | className 17 | ) 18 | return ( 19 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { StatusIcons } from '../types/types' 3 | 4 | interface Props { 5 | status: string 6 | className?: string 7 | } 8 | 9 | export default function StatusIcon({ status, className }: Props) { 10 | const classes = classNames(`w-3.5 h-3.5 rounded`, className) 11 | 12 | const Icon = StatusIcons[status.toLowerCase()] 13 | 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | 3 | interface Props { 4 | onChange?: (value: boolean) => void 5 | className?: string 6 | value?: boolean 7 | activeClass?: string 8 | activeLabelClass?: string 9 | } 10 | export default function Toggle({ 11 | onChange, 12 | className, 13 | value = false, 14 | activeClass = `bg-indigo-600 hover:bg-indigo-700`, 15 | activeLabelClass = `border-indigo-600`, 16 | }: Props) { 17 | const labelClasses = classnames( 18 | `absolute h-3.5 w-3.5 overflow-hidden border-2 transition duration-200 ease-linear rounded-full cursor-pointer bg-white`, 19 | { 20 | 'left-0 border-gray-300': !value, 21 | 'right-0': value, 22 | [activeLabelClass]: value, 23 | } 24 | ) 25 | const classes = classnames( 26 | `group relative rounded-full w-5 h-3.5 transition duration-200 ease-linear`, 27 | { 28 | [activeClass]: value, 29 | 'bg-gray-300': !value, 30 | }, 31 | className 32 | ) 33 | const onClick = () => { 34 | if (onChange) onChange(!value) 35 | } 36 | return ( 37 |
38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/contextmenu/PriorityMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from '../Portal' 2 | import { ReactNode, useState } from 'react' 3 | import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' 4 | import { Menu } from './menu' 5 | import { PriorityOptions } from '../../types/types' 6 | 7 | interface Props { 8 | id: string 9 | button: ReactNode 10 | filterKeyword?: boolean 11 | className?: string 12 | onSelect?: (item: string) => void 13 | } 14 | 15 | function PriorityMenu({ 16 | id, 17 | button, 18 | filterKeyword = false, 19 | className, 20 | onSelect, 21 | }: Props) { 22 | const [keyword, setKeyword] = useState(``) 23 | 24 | const handleSelect = (priority: string) => { 25 | setKeyword(``) 26 | if (onSelect) onSelect(priority) 27 | } 28 | let statusOpts = PriorityOptions 29 | if (keyword !== ``) { 30 | const normalizedKeyword = keyword.toLowerCase().trim() 31 | statusOpts = statusOpts.filter( 32 | ([_Icon, _priority, label]) => 33 | (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1 34 | ) 35 | } 36 | 37 | const options = statusOpts.map(([Icon, priority, label], idx) => { 38 | return ( 39 | handleSelect(priority as string)} 42 | > 43 | {label} 44 | 45 | ) 46 | }) 47 | 48 | return ( 49 | <> 50 | 51 | {button} 52 | 53 | 54 | 55 | setKeyword(kw)} 61 | className={className} 62 | > 63 | {options} 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default PriorityMenu 71 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/contextmenu/StatusMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from '../Portal' 2 | import { ReactNode, useState } from 'react' 3 | import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' 4 | import { StatusOptions } from '../../types/types' 5 | import { Menu } from './menu' 6 | 7 | interface Props { 8 | id: string 9 | button: ReactNode 10 | className?: string 11 | onSelect?: (item: unknown) => void 12 | } 13 | export default function StatusMenu({ id, button, className, onSelect }: Props) { 14 | const [keyword, setKeyword] = useState(``) 15 | const handleSelect = (status: string) => { 16 | if (onSelect) onSelect(status) 17 | } 18 | 19 | let statuses = StatusOptions 20 | if (keyword !== ``) { 21 | const normalizedKeyword = keyword.toLowerCase().trim() 22 | statuses = statuses.filter( 23 | ([_icon, _id, l]) => l.toLowerCase().indexOf(normalizedKeyword) !== -1 24 | ) 25 | } 26 | 27 | const options = statuses.map(([Icon, id, label]) => { 28 | return ( 29 | handleSelect(id)}> 30 | 31 |
{label}
32 |
33 | ) 34 | }) 35 | 36 | return ( 37 | <> 38 | 39 | {button} 40 | 41 | 42 | 43 | setKeyword(kw)} 50 | > 51 | {options} 52 | 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /examples/linearlite/src/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEditor, 3 | EditorContent, 4 | BubbleMenu, 5 | type Extensions, 6 | } from '@tiptap/react' 7 | import StarterKit from '@tiptap/starter-kit' 8 | import Placeholder from '@tiptap/extension-placeholder' 9 | import Table from '@tiptap/extension-table' 10 | import TableCell from '@tiptap/extension-table-cell' 11 | import TableHeader from '@tiptap/extension-table-header' 12 | import TableRow from '@tiptap/extension-table-row' 13 | import { Markdown } from 'tiptap-markdown' 14 | import EditorMenu from './EditorMenu' 15 | import { useEffect, useRef } from 'react' 16 | 17 | interface EditorProps { 18 | value: string 19 | onChange: (value: string) => void 20 | className?: string 21 | placeholder?: string 22 | } 23 | 24 | const Editor = ({ 25 | value, 26 | onChange, 27 | className = ``, 28 | placeholder, 29 | }: EditorProps) => { 30 | const editorProps = { 31 | attributes: { 32 | class: className, 33 | }, 34 | } 35 | const markdownValue = useRef(null) 36 | 37 | const extensions: Extensions = [ 38 | StarterKit, 39 | Markdown, 40 | Table, 41 | TableRow, 42 | TableHeader, 43 | TableCell, 44 | ] 45 | 46 | const editor = useEditor({ 47 | extensions, 48 | editorProps, 49 | content: value || undefined, 50 | onUpdate: ({ editor }) => { 51 | markdownValue.current = editor.storage.markdown.getMarkdown() 52 | onChange(markdownValue.current || ``) 53 | }, 54 | }) 55 | 56 | useEffect(() => { 57 | if (editor && markdownValue.current !== value) { 58 | editor.commands.setContent(value) 59 | } 60 | }, [value]) 61 | 62 | if (placeholder) { 63 | extensions.push( 64 | Placeholder.configure({ 65 | placeholder, 66 | }) 67 | ) 68 | } 69 | 70 | return ( 71 | <> 72 | 73 | {editor && ( 74 | 75 | 76 | 77 | )} 78 | 79 | ) 80 | } 81 | 82 | export default Editor 83 | -------------------------------------------------------------------------------- /examples/linearlite/src/electric.tsx: -------------------------------------------------------------------------------- 1 | export const baseUrl = import.meta.env.ELECTRIC_URL ?? `http://localhost:3000` 2 | -------------------------------------------------------------------------------- /examples/linearlite/src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect } from 'react' 2 | 3 | export const useClickOutside = ( 4 | ref: RefObject, 5 | callback: (event: MouseEvent | TouchEvent) => void, 6 | outerRef?: RefObject 7 | ): void => { 8 | const handleClick = useCallback( 9 | (event: MouseEvent | TouchEvent) => { 10 | if (!event.target || outerRef?.current?.contains(event.target as Node)) { 11 | return 12 | } 13 | if (ref.current && !ref.current.contains(event.target as Node)) { 14 | callback(event) 15 | } 16 | }, 17 | [callback, ref] 18 | ) 19 | useEffect(() => { 20 | document.addEventListener(`mousedown`, handleClick) 21 | document.addEventListener(`touchstart`, handleClick) 22 | 23 | return () => { 24 | document.removeEventListener(`mousedown`, handleClick) 25 | document.removeEventListener(`touchstart`, handleClick) 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /examples/linearlite/src/hooks/useLockBodyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | export default function useLockBodyScroll() { 4 | useLayoutEffect(() => { 5 | // Get original value of body overflow 6 | const originalStyle = window.getComputedStyle(document.body).overflow 7 | // Prevent scrolling on mount 8 | document.body.style.overflow = `hidden` 9 | // Re-enable scrolling when component unmounts 10 | return () => { 11 | document.body.style.overflow = originalStyle 12 | } 13 | }, []) // Empty array ensures effect is only run on mount and unmount 14 | } 15 | -------------------------------------------------------------------------------- /examples/linearlite/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import './style.css' 3 | 4 | import App from './App' 5 | 6 | const container = document.getElementById(`root`)! 7 | const root = createRoot(container) 8 | root.render() 9 | 10 | // If you want to start measuring performance in your app, pass a function 11 | // to log results (for example: reportWebVitals(console.log)) 12 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 13 | //reportWebVitals(); 14 | 15 | // If you want your app to work offline and load faster, you can change 16 | // unregister() to register() below. Note this comes with some pitfalls. 17 | // Learn more about service workers: https://cra.link/PWA 18 | //serviceWorkerRegistration.register(); 19 | -------------------------------------------------------------------------------- /examples/linearlite/src/pages/Board/index.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | import TopFilter from '../../components/TopFilter' 4 | import IssueBoard from './IssueBoard' 5 | import { useFilterState } from '../../utils/filterState' 6 | import { useShape } from '@electric-sql/react' 7 | import { Issue } from '../../types/types' 8 | import { issueShape } from '../../shapes' 9 | 10 | function Board() { 11 | const [_filterState] = useFilterState() 12 | const { data: issues } = useShape(issueShape)! as unknown as { 13 | data: Issue[] 14 | } 15 | 16 | // TODO: apply filter state 17 | 18 | return ( 19 |
20 | 21 | 22 |
23 | ) 24 | } 25 | 26 | export default Board 27 | -------------------------------------------------------------------------------- /examples/linearlite/src/pages/Issue/DeleteModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '../../components/Modal' 2 | 3 | interface Props { 4 | isOpen: boolean 5 | setIsOpen: (isOpen: boolean) => void 6 | onDismiss?: () => void 7 | deleteIssue: () => void 8 | } 9 | 10 | export default function AboutModal({ 11 | isOpen, 12 | setIsOpen, 13 | onDismiss, 14 | deleteIssue, 15 | }: Props) { 16 | const handleDelete = () => { 17 | setIsOpen(false) 18 | if (onDismiss) onDismiss() 19 | deleteIssue() 20 | } 21 | 22 | return ( 23 | 24 |
25 | Are you sure you want to delete this issue? 26 |
27 |
28 | 37 | 43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /examples/linearlite/src/pages/List/IssueList.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react' 2 | import { FixedSizeList as List, areEqual } from 'react-window' 3 | import { memo } from 'react' 4 | import AutoSizer from 'react-virtualized-auto-sizer' 5 | import IssueRow from './IssueRow' 6 | import { Issue } from '../../types/types' 7 | 8 | export interface IssueListProps { 9 | issues: Issue[] 10 | } 11 | 12 | function IssueList({ issues }: IssueListProps) { 13 | return ( 14 |
15 | 16 | {({ height, width }) => ( 17 | 24 | {VirtualIssueRow} 25 | 26 | )} 27 | 28 |
29 | ) 30 | } 31 | 32 | const VirtualIssueRow = memo( 33 | ({ 34 | data: issues, 35 | index, 36 | style, 37 | }: { 38 | data: Issue[] 39 | index: number 40 | style: CSSProperties 41 | }) => { 42 | const issue = issues[index] 43 | return 44 | }, 45 | areEqual 46 | ) 47 | 48 | export default IssueList 49 | -------------------------------------------------------------------------------- /examples/linearlite/src/pages/List/index.tsx: -------------------------------------------------------------------------------- 1 | import TopFilter from '../../components/TopFilter' 2 | import IssueList from './IssueList' 3 | import { useFilterState } from '../../utils/filterState' 4 | import { useShape } from '@electric-sql/react' 5 | import { Issue } from '../../types/types' 6 | import { issueShape } from '../../shapes' 7 | 8 | function List({ showSearch = false }) { 9 | const [filterState] = useFilterState() 10 | 11 | const { data: issues } = useShape(issueShape) as unknown as { data: Issue[] } 12 | 13 | const filteredIssues = issues.filter((issue) => { 14 | const tests = [true] 15 | if (filterState.priority && filterState.priority.length > 0) { 16 | tests.push(filterState.priority.includes(issue.priority)) 17 | } 18 | if (filterState.status && filterState.status.length > 0) { 19 | tests.push(filterState.status.includes(issue.status)) 20 | } 21 | 22 | if (typeof filterState.query !== `undefined`) { 23 | tests.push(issue.title.includes(filterState.query)) 24 | } 25 | 26 | // Return true only if all tests are true 27 | return tests.every((test) => test) 28 | }) 29 | 30 | return ( 31 |
32 | 33 | 34 |
35 | ) 36 | } 37 | 38 | export default List 39 | -------------------------------------------------------------------------------- /examples/linearlite/src/pages/root.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | import LeftMenu from '../components/LeftMenu' 3 | import { cssTransition, ToastContainer } from 'react-toastify' 4 | 5 | const slideUp = cssTransition({ 6 | enter: `animate__animated animate__slideInUp`, 7 | exit: `animate__animated animate__slideOutDown`, 8 | }) 9 | 10 | export default function Root() { 11 | return ( 12 |
13 |
14 | 15 | 16 |
17 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /examples/linearlite/src/shapes.ts: -------------------------------------------------------------------------------- 1 | import { baseUrl } from './electric' 2 | 3 | export const issueShape = { 4 | url: `${baseUrl}/v1/shape/issue`, 5 | } 6 | -------------------------------------------------------------------------------- /examples/linearlite/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | body { 5 | font-size: 12px; 6 | @apply font-medium text-gray-600; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Inter UI'; 11 | font-style: normal; 12 | font-weight: 400; 13 | font-display: swap; 14 | src: url('assets/fonts/Inter-UI-Regular.woff2') format('woff2'), 15 | url('assets/fonts/Inter-UI-Regular.woff') format('woff'); 16 | } 17 | 18 | @font-face { 19 | font-family: 'Inter UI'; 20 | font-style: normal; 21 | font-weight: 500; 22 | font-display: swap; 23 | src: url('assets/fonts/Inter-UI-Medium.woff2') format('woff2'), 24 | url('assets/fonts/Inter-UI-Medium.woff') format('woff'); 25 | } 26 | 27 | @font-face { 28 | font-family: 'Inter UI'; 29 | font-style: normal; 30 | font-weight: 600; 31 | font-display: swap; 32 | src: url('assets/fonts/Inter-UI-SemiBold.woff2') format('woff2'), 33 | url('assets/fonts/Inter-UI-SemiBold.woff') format('woff'); 34 | } 35 | 36 | @font-face { 37 | font-family: 'Inter UI'; 38 | font-style: normal; 39 | font-weight: 800; 40 | font-display: swap; 41 | src: url('assets/fonts/Inter-UI-ExtraBold.woff2') format('woff2'), 42 | url('assets/fonts/Inter-UI-ExtraBold.woff') format('woff'); 43 | } 44 | 45 | .modal { 46 | max-width: calc(100vw - 32px); 47 | max-height: calc(100vh - 32px); 48 | } 49 | 50 | .editor ul { 51 | list-style-type: circle; 52 | } 53 | .editor ol { 54 | list-style-type: decimal; 55 | } 56 | 57 | #root, 58 | body, 59 | html { 60 | height: 100%; 61 | } 62 | 63 | .tiptap p.is-editor-empty:first-child::before { 64 | color: #adb5bd; 65 | content: attr(data-placeholder); 66 | float: left; 67 | height: 0; 68 | pointer-events: none; 69 | } 70 | -------------------------------------------------------------------------------- /examples/linearlite/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | export function formatDate(date?: Date): string { 4 | if (!date) return `` 5 | return dayjs(date).format(`D MMM`) 6 | } 7 | -------------------------------------------------------------------------------- /examples/linearlite/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/linearlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["vite/client", "vite-plugin-svgr/client"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"] 25 | } 26 | -------------------------------------------------------------------------------- /examples/linearlite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import svgr from 'vite-plugin-svgr' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | svgr({ 10 | svgrOptions: { 11 | svgo: true, 12 | plugins: [`@svgr/plugin-svgo`, `@svgr/plugin-jsx`], 13 | svgoConfig: { 14 | plugins: [ 15 | `preset-default`, 16 | `removeTitle`, 17 | `removeDesc`, 18 | `removeDoctype`, 19 | `cleanupIds`, 20 | ], 21 | }, 22 | }, 23 | }), 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /examples/nextjs-example/.eslintignore: -------------------------------------------------------------------------------- 1 | /build/** 2 | -------------------------------------------------------------------------------- /examples/nextjs-example/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | "no-unused-vars": `off`, 25 | "@typescript-eslint/no-unused-vars": [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | ignorePatterns: [ 35 | `**/node_modules/**`, 36 | `**/dist/**`, 37 | `tsup.config.ts`, 38 | `vitest.config.ts`, 39 | `.eslintrc.js`, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /examples/nextjs-example/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env.local 3 | 4 | # Turborepo 5 | .turbo 6 | 7 | # next.js 8 | /.next/ 9 | /out/ 10 | next-env.d.ts 11 | -------------------------------------------------------------------------------- /examples/nextjs-example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /examples/nextjs-example/README.md: -------------------------------------------------------------------------------- 1 | # Basic Remix example 2 | 3 | ## Setup 4 | 5 | 1. Make sure you've installed all dependencies for the monorepo and built packages 6 | 7 | From the root directory: 8 | 9 | - `pnpm i` 10 | - `pnpm run -r build` 11 | 12 | 2. Start the docker containers 13 | 14 | `pnpm run backend:up` 15 | 16 | 3. Start the dev server 17 | 18 | `pnpm run dev` 19 | 20 | 4. When done, tear down the backend containers so you can run other examples 21 | 22 | `pnpm run backend:down` 23 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: min(160px, 30vmin); 7 | pointer-events: none; 8 | margin-top: min(30px, 5vmin); 9 | margin-bottom: min(30px, 5vmin); 10 | } 11 | 12 | .App-header { 13 | background-color: #1c1e20; 14 | min-height: 100vh; 15 | display: flex; 16 | flex-direction: column; 17 | align-items: top; 18 | justify-content: top; 19 | font-size: calc(10px + 2vmin); 20 | color: white; 21 | } 22 | 23 | .App-link { 24 | color: #61dafb; 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/Example.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | margin-bottom: 1.5rem; 3 | } 4 | 5 | .button { 6 | display: inline-block; 7 | line-height: 1.3; 8 | text-align: center; 9 | text-decoration: none; 10 | vertical-align: middle; 11 | cursor: pointer; 12 | user-select: none; 13 | width: calc(15vw + 100px); 14 | margin-right: 0.5rem !important; 15 | margin-left: 0.5rem !important; 16 | border-radius: 32px; 17 | text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4); 18 | box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px; 19 | background: #1e2123; 20 | border: 2px solid #229089; 21 | color: #f9fdff; 22 | font-size: 16px; 23 | font-weight: 500; 24 | padding: 10px 18px; 25 | } 26 | 27 | .item { 28 | display: block; 29 | line-height: 1.3; 30 | text-align: center; 31 | vertical-align: middle; 32 | width: calc(30vw - 1.5rem + 200px); 33 | margin-right: auto; 34 | margin-left: auto; 35 | border-radius: 32px; 36 | border: 1.5px solid #bbb; 37 | box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px; 38 | color: #f9fdff; 39 | font-size: 13px; 40 | padding: 10px 18px; 41 | } 42 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/api/items/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "../../db" 2 | import { NextResponse } from "next/server" 3 | 4 | export async function POST(request: Request) { 5 | const body = await request.json() 6 | const result = await db.query( 7 | `INSERT INTO items (id) 8 | VALUES ($1) RETURNING id;`, 9 | [body.uuid] 10 | ) 11 | return NextResponse.json({ id: result.rows[0].id }) 12 | } 13 | 14 | export async function DELETE() { 15 | await db.query(`DELETE FROM items;`) 16 | return NextResponse.json(`ok`) 17 | } 18 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/db.ts: -------------------------------------------------------------------------------- 1 | import pgPkg from "pg" 2 | const { Client } = pgPkg 3 | 4 | const db = new Client({ 5 | host: `localhost`, 6 | port: 54321, 7 | password: `password`, 8 | user: `postgres`, 9 | database: `electric`, 10 | }) 11 | 12 | db.connect() 13 | 14 | export { db } 15 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css" 2 | import "./App.css" 3 | import { Providers } from "./providers" 4 | 5 | export const metadata = { 6 | title: `Next.js Forms Example`, 7 | description: `Example application with forms and Postgres.`, 8 | } 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode 14 | }) { 15 | return ( 16 | 17 | 18 |
19 |
20 | logo 21 | {children} 22 |
23 |
24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/match-stream.ts: -------------------------------------------------------------------------------- 1 | import { ShapeStream, ChangeMessage } from "@electric-sql/next" 2 | 3 | export async function matchStream({ 4 | stream, 5 | operations, 6 | matchFn, 7 | timeout = 10000, 8 | }: { 9 | stream: ShapeStream 10 | operations: Array<`insert` | `update` | `delete`> 11 | matchFn: ({ 12 | operationType, 13 | message, 14 | }: { 15 | operationType: string 16 | message: ChangeMessage<{ [key: string]: T }> 17 | }) => boolean 18 | timeout?: number 19 | }): Promise> { 20 | return new Promise((resolve, reject) => { 21 | const unsubscribe = stream.subscribe((messages) => { 22 | for (const message of messages) { 23 | if (`key` in message && operations.includes(message.headers.action)) { 24 | if ( 25 | matchFn({ 26 | operationType: message.headers.action, 27 | message: message as ChangeMessage<{ [key: string]: T }>, 28 | }) 29 | ) { 30 | return finish(message as ChangeMessage<{ [key: string]: T }>) 31 | } 32 | } 33 | } 34 | }) 35 | 36 | const timeoutId = setTimeout(() => { 37 | console.error(`matchStream timed out after ${timeout}ms`) 38 | reject(`matchStream timed out after ${timeout}ms`) 39 | }, timeout) 40 | 41 | function finish(message: ChangeMessage<{ [key: string]: T }>) { 42 | clearTimeout(timeoutId) 43 | unsubscribe() 44 | return resolve(message) 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ShapesProvider } from "@electric-sql/react" 4 | import { ReactNode } from "react" 5 | 6 | export function Providers({ children }: { children: ReactNode }) { 7 | return {children} 8 | } 9 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/shape-proxy/[...table]/route.ts: -------------------------------------------------------------------------------- 1 | export async function GET( 2 | request: Request, 3 | { params }: { params: { table: string } } 4 | ) { 5 | const url = new URL(request.url) 6 | const { table } = params 7 | const originUrl = new URL(`http://localhost:3000/v1/shape/${table}`) 8 | url.searchParams.forEach((value, key) => { 9 | originUrl.searchParams.set(key, value) 10 | }) 11 | 12 | // When proxying long-polling requests, content-encoding & content-length are added 13 | // erroneously (saying the body is gzipped when it's not) so we'll just remove 14 | // them to avoid content decoding errors in the browser. 15 | // 16 | // Similar-ish problem to https://github.com/wintercg/fetch/issues/23 17 | let resp = await fetch(originUrl.toString()) 18 | if (resp.headers.get(`content-encoding`)) { 19 | const headers = new Headers(resp.headers) 20 | headers.delete(`content-encoding`) 21 | headers.delete(`content-length`) 22 | resp = new Response(resp.body, { 23 | status: resp.status, 24 | statusText: resp.statusText, 25 | headers, 26 | }) 27 | } 28 | return resp 29 | } 30 | -------------------------------------------------------------------------------- /examples/nextjs-example/app/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: "Helvetica Neue", Helvetica, sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | background: #1c1e20; 7 | min-width: 360px; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/nextjs-example/db/migrations/01-create_items_table.sql: -------------------------------------------------------------------------------- 1 | -- Create a simple items table. 2 | CREATE TABLE IF NOT EXISTS items ( 3 | id TEXT PRIMARY KEY NOT NULL 4 | ); 5 | 6 | -- Populate the table with 10 items. 7 | -- FIXME: Remove this once writing out of band is implemented 8 | WITH generate_series AS ( 9 | SELECT gen_random_uuid()::text AS id 10 | FROM generate_series(1, 10) 11 | ) 12 | INSERT INTO items (id) 13 | SELECT id 14 | FROM generate_series; 15 | -------------------------------------------------------------------------------- /examples/nextjs-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Web Example - ElectricSQL 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/nextjs-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-examples/basic-example", 3 | "private": true, 4 | "version": "0.0.1", 5 | "author": "ElectricSQL", 6 | "license": "Apache-2.0", 7 | "type": "module", 8 | "scripts": { 9 | "backend:up": "PROJECT_NAME=nextjs-basic-example pnpm -C ../../ run example-backend:up && pnpm db:migrate", 10 | "backend:down": "PROJECT_NAME=nextjs-basic-example pnpm -C ../../ run example-backend:down", 11 | "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations", 12 | "dev": "next dev --turbo -p 5173", 13 | "build": "next build", 14 | "start": "next start", 15 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 16 | "stylecheck": "eslint . --quiet", 17 | "typecheck": "tsc --noEmit" 18 | }, 19 | "dependencies": { 20 | "@electric-sql/next": "workspace:*", 21 | "@electric-sql/react": "workspace:*", 22 | "next": "^14.2.5", 23 | "pg": "^8.12.0", 24 | "react": "^18.3.1", 25 | "react-dom": "^18.3.1", 26 | "uuid": "^10.0.0", 27 | "zod": "^3.23.8" 28 | }, 29 | "devDependencies": { 30 | "@databases/pg-migrations": "^5.0.3", 31 | "@types/pg": "^8.11.6", 32 | "@types/react": "^18.3.3", 33 | "@types/react-dom": "^18.3.0", 34 | "@types/uuid": "*", 35 | "@vitejs/plugin-react": "^4.3.1", 36 | "dotenv": "^16.4.5", 37 | "eslint": "^8.57.0", 38 | "typescript": "^5.5.3", 39 | "vite": "^5.3.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/nextjs-example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/nextjs-example/public/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs-example/public/logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /examples/nextjs-example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/nextjs-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /examples/redis-client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | 'no-unused-vars': `off`, 25 | '@typescript-eslint/no-unused-vars': [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /examples/redis-client/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /examples/redis-client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /examples/redis-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-examples/redis-client", 3 | "version": "0.0.1", 4 | "description": "An example Redis client for ElectricSQL", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "private": true, 11 | "scripts": { 12 | "backend:up": "PROJECT_NAME=redis-client pnpm -C ../../ run example-backend:up", 13 | "backend:down": "PROJECT_NAME=redis-client pnpm -C ../../ run example-backend:down", 14 | "typecheck": "tsc -p tsconfig.json", 15 | "stylecheck": "eslint './*.{ts,js,tsx,jsx}' './tests/**/*.{ts,tsx}' './examples/**/*.{ts,tsx}' --no-error-on-unmatched-pattern" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/electric-sql/electric-next.git" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/electric-sql/electric-next/issues" 25 | }, 26 | "homepage": "https://github.com/electric-sql/electric-next#readme", 27 | "dependencies": { 28 | "@electric-sql/next": "workspace:*", 29 | "redis": "^4.6.14" 30 | }, 31 | "devDependencies": { 32 | "@typescript-eslint/eslint-plugin": "^7.14.1", 33 | "@typescript-eslint/parser": "^7.14.1", 34 | "concurrently": "^8.2.2", 35 | "eslint": "^8.57.0", 36 | "eslint-config-prettier": "^9.1.0", 37 | "eslint-plugin-prettier": "^5.1.3", 38 | "glob": "^10.3.10", 39 | "prettier": "^3.3.2", 40 | "shx": "^0.3.4", 41 | "tsup": "^8.0.1", 42 | "typescript": "^5.5.2" 43 | }, 44 | "optionalDependencies": { 45 | "@rollup/rollup-darwin-arm64": "^4.18.1" 46 | }, 47 | "exports": { 48 | ".": "./dist/index.js" 49 | }, 50 | "typesVersions": { 51 | "*": { 52 | "*": [ 53 | "./dist/index.d.ts" 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/redis-client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis' 2 | import { ShapeStream, Message } from '@electric-sql/next' 3 | 4 | // Create a Redis client 5 | const REDIS_HOST = `localhost` 6 | const REDIS_PORT = 6379 7 | const client = createClient({ 8 | url: `redis://${REDIS_HOST}:${REDIS_PORT}`, 9 | }) 10 | 11 | client.connect().then(() => { 12 | console.log(`Connected to Redis server`) 13 | 14 | const issueStream = new ShapeStream({ 15 | url: `http://localhost:3000/v1/shape/todos`, 16 | }) 17 | issueStream.subscribe(async (messages: Message[]) => { 18 | console.log(`messages`, messages) 19 | // Begin a Redis transaction 20 | const pipeline = client.multi() 21 | 22 | // Loop through each message and make writes to the Redis hash for action messages 23 | messages.forEach((message) => { 24 | if (!(`key` in message)) return 25 | // Upsert/delete 26 | switch (message.headers.action) { 27 | case `delete`: 28 | pipeline.hDel(`issues`, message.key) 29 | break 30 | 31 | case `insert`: 32 | case `update`: 33 | pipeline.hSet( 34 | `issues`, 35 | String(message.key), 36 | JSON.stringify(message.value) 37 | ) 38 | break 39 | } 40 | }) 41 | 42 | // Execute all commands as a single transaction 43 | try { 44 | await pipeline.exec() 45 | console.log(`Redis hash updated successfully with latest shape updates`) 46 | } catch (error) { 47 | console.error(`Error while updating hash:`, error) 48 | } 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /examples/remix-basic/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | "no-unused-vars": `off`, 25 | "@typescript-eslint/no-unused-vars": [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | ignorePatterns: [ 35 | `**/node_modules/**`, 36 | `**/dist/**`, 37 | `tsup.config.ts`, 38 | `vitest.config.ts`, 39 | `.eslintrc.js`, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /examples/remix-basic/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env.local 3 | -------------------------------------------------------------------------------- /examples/remix-basic/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /examples/remix-basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Remix example 2 | 3 | ## Setup 4 | 5 | 1. Make sure you've installed all dependencies for the monorepo and built packages 6 | 7 | From the root directory: 8 | 9 | - `pnpm i` 10 | - `pnpm run -r build` 11 | 12 | 2. Start the docker containers 13 | 14 | `pnpm run backend:up` 15 | 16 | 3. Start the dev server 17 | 18 | `pnpm run dev` 19 | 20 | 4. When done, tear down the backend containers so you can run other examples 21 | 22 | `pnpm run backend:down` 23 | -------------------------------------------------------------------------------- /examples/remix-basic/app/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: min(160px, 30vmin); 7 | pointer-events: none; 8 | margin-top: min(30px, 5vmin); 9 | margin-bottom: min(30px, 5vmin); 10 | } 11 | 12 | .App-header { 13 | background-color: #1c1e20; 14 | min-height: 100vh; 15 | display: flex; 16 | flex-direction: column; 17 | align-items: top; 18 | justify-content: top; 19 | font-size: calc(10px + 2vmin); 20 | color: white; 21 | } 22 | 23 | .App-link { 24 | color: #61dafb; 25 | } 26 | -------------------------------------------------------------------------------- /examples/remix-basic/app/Example.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | margin-bottom: 1.5rem; 3 | } 4 | 5 | .button { 6 | display: inline-block; 7 | line-height: 1.3; 8 | text-align: center; 9 | text-decoration: none; 10 | vertical-align: middle; 11 | cursor: pointer; 12 | user-select: none; 13 | width: calc(15vw + 100px); 14 | margin-right: 0.5rem !important; 15 | margin-left: 0.5rem !important; 16 | border-radius: 32px; 17 | text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4); 18 | box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px; 19 | background: #1e2123; 20 | border: 2px solid #229089; 21 | color: #f9fdff; 22 | font-size: 16px; 23 | font-weight: 500; 24 | padding: 10px 18px; 25 | } 26 | 27 | .item { 28 | display: block; 29 | line-height: 1.3; 30 | text-align: center; 31 | vertical-align: middle; 32 | width: calc(30vw - 1.5rem + 200px); 33 | margin-right: auto; 34 | margin-left: auto; 35 | border-radius: 32px; 36 | border: 1.5px solid #bbb; 37 | box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px; 38 | color: #f9fdff; 39 | font-size: 13px; 40 | padding: 10px 18px; 41 | } 42 | -------------------------------------------------------------------------------- /examples/remix-basic/app/db.ts: -------------------------------------------------------------------------------- 1 | import pgPkg from "pg" 2 | const { Client } = pgPkg 3 | 4 | const db = new Client({ 5 | host: `localhost`, 6 | port: 54321, 7 | password: `password`, 8 | user: `postgres`, 9 | database: `electric`, 10 | }) 11 | 12 | db.connect() 13 | 14 | export { db } 15 | -------------------------------------------------------------------------------- /examples/remix-basic/app/match-stream.ts: -------------------------------------------------------------------------------- 1 | import { ShapeStream, ChangeMessage } from "@electric-sql/next" 2 | 3 | export async function matchStream({ 4 | stream, 5 | operations, 6 | matchFn, 7 | timeout = 10000, 8 | }: { 9 | stream: ShapeStream 10 | operations: Array<`insert` | `update` | `delete`> 11 | matchFn: ({ 12 | operationType, 13 | message, 14 | }: { 15 | operationType: string 16 | message: ChangeMessage 17 | }) => boolean 18 | timeout?: number 19 | }): Promise> { 20 | return new Promise((resolve, reject) => { 21 | const unsubscribe = stream.subscribe((messages) => { 22 | for (const message of messages) { 23 | if (`key` in message && operations.includes(message.headers.action)) { 24 | if (matchFn({ operationType: message.headers.action, message })) { 25 | return finish(message) 26 | } 27 | } 28 | } 29 | }) 30 | 31 | const timeoutId = setTimeout(() => { 32 | console.error(`matchStream timed out after ${timeout}ms`) 33 | reject(`matchStream timed out after ${timeout}ms`) 34 | }, timeout) 35 | 36 | function finish(message: ChangeMessage) { 37 | clearTimeout(timeoutId) 38 | unsubscribe() 39 | return resolve(message) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /examples/remix-basic/app/root.tsx: -------------------------------------------------------------------------------- 1 | import "./style.css" 2 | import "./App.css" 3 | import { 4 | Links, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react" 10 | 11 | import { ShapesProvider } from "@electric-sql/react" 12 | 13 | export function Layout({ children }: { children: React.ReactNode }) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default function App() { 32 | return ( 33 |
34 |
35 | logo 36 | 37 | 38 | 39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /examples/remix-basic/app/routes/api.items.ts: -------------------------------------------------------------------------------- 1 | import nodePkg from "@remix-run/node" 2 | const { json } = nodePkg 3 | import type { ActionFunctionArgs } from "@remix-run/node" 4 | import { db } from "../db" 5 | 6 | export async function action({ request }: ActionFunctionArgs) { 7 | if (request.method === `POST`) { 8 | const body = await request.json() 9 | const result = await db.query( 10 | `INSERT INTO items (id) 11 | VALUES ($1) RETURNING id;`, 12 | [body.uuid] 13 | ) 14 | return json({ id: result.rows[0].id }) 15 | } 16 | 17 | if (request.method === `DELETE`) { 18 | await db.query(`DELETE FROM items;`) 19 | 20 | return `ok` 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/remix-basic/app/routes/shape-proxy.$table.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node" 2 | 3 | export async function loader({ params, request }: LoaderFunctionArgs) { 4 | const url = new URL(request.url) 5 | const { table } = params 6 | const originUrl = new URL(`http://localhost:3000/v1/shape/${table}`) 7 | url.searchParams.forEach((value, key) => { 8 | originUrl.searchParams.set(key, value) 9 | }) 10 | 11 | // When proxying long-polling requests, content-encoding & content-length are added 12 | // erroneously (saying the body is gzipped when it's not) so we'll just remove 13 | // them to avoid content decoding errors in the browser. 14 | // 15 | // Similar-ish problem to https://github.com/wintercg/fetch/issues/23 16 | let resp = await fetch(originUrl.toString()) 17 | if (resp.headers.get(`content-encoding`)) { 18 | const headers = new Headers(resp.headers) 19 | headers.delete(`content-encoding`) 20 | headers.delete(`content-length`) 21 | resp = new Response(resp.body, { 22 | status: resp.status, 23 | statusText: resp.statusText, 24 | headers, 25 | }) 26 | } 27 | return resp 28 | } 29 | -------------------------------------------------------------------------------- /examples/remix-basic/app/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: "Helvetica Neue", Helvetica, sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | background: #1c1e20; 7 | min-width: 360px; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/remix-basic/db/migrations/01-create_items_table.sql: -------------------------------------------------------------------------------- 1 | -- Create a simple items table. 2 | CREATE TABLE IF NOT EXISTS items ( 3 | id TEXT PRIMARY KEY NOT NULL 4 | ); 5 | 6 | -- Populate the table with 10 items. 7 | -- FIXME: Remove this once writing out of band is implemented 8 | WITH generate_series AS ( 9 | SELECT gen_random_uuid()::text AS id 10 | FROM generate_series(1, 10) 11 | ) 12 | INSERT INTO items (id) 13 | SELECT id 14 | FROM generate_series; 15 | -------------------------------------------------------------------------------- /examples/remix-basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Web Example - ElectricSQL 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/remix-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electric-examples/basic-example", 3 | "private": true, 4 | "version": "0.0.1", 5 | "author": "ElectricSQL", 6 | "license": "Apache-2.0", 7 | "type": "module", 8 | "scripts": { 9 | "backend:up": "PROJECT_NAME=remix-basic-example pnpm -C ../../ run example-backend:up && pnpm db:migrate", 10 | "backend:down": "PROJECT_NAME=remix-basic-example pnpm -C ../../ run example-backend:down", 11 | "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations", 12 | "dev": "vite", 13 | "build": "vite build", 14 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 15 | "preview": "vite preview", 16 | "typecheck": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "@electric-sql/next": "workspace:*", 20 | "@electric-sql/react": "workspace:*", 21 | "@remix-run/dev": "^2.11.0", 22 | "@remix-run/node": "^2.11.0", 23 | "@remix-run/react": "^2.11.0", 24 | "@remix-run/serve": "^2.11.0", 25 | "isbot": "^4", 26 | "pg": "^8.12.0", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "uuid": "^10.0.0" 30 | }, 31 | "devDependencies": { 32 | "@databases/pg-migrations": "^5.0.3", 33 | "@types/react": "^18.3.3", 34 | "@types/react-dom": "^18.3.0", 35 | "@types/pg": "^8.11.6", 36 | "@types/uuid": "*", 37 | "@vitejs/plugin-react": "^4.3.1", 38 | "dotenv": "^16.4.5", 39 | "eslint": "^8.57.0", 40 | "typescript": "^5.5.3", 41 | "vite": "^5.3.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/remix-basic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electric-sql/archived-electric-next/4d0ee1286911dd3da8ab8cde134bec6a1d1ab05a/examples/remix-basic/public/favicon.ico -------------------------------------------------------------------------------- /examples/remix-basic/public/logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /examples/remix-basic/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/remix-basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["app"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/remix-basic/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import { vitePlugin as remix } from "@remix-run/dev" 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [ 7 | remix({ 8 | // ssr: false, 9 | future: { 10 | v3_fetcherPersist: true, 11 | v3_relativeSplatPath: true, 12 | v3_throwAbortReason: true, 13 | }, 14 | }), 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /examples/todo-app/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | `eslint:recommended`, 6 | `plugin:@typescript-eslint/recommended`, 7 | `plugin:react-hooks/recommended`, 8 | `plugin:prettier/recommended`, 9 | ], 10 | ignorePatterns: [`dist`, `.eslintrc.cjs`], 11 | parser: `@typescript-eslint/parser`, 12 | plugins: [`react-refresh`, `prettier`], 13 | rules: { 14 | quotes: [`error`, `backtick`], 15 | "react-refresh/only-export-components": [ 16 | `warn`, 17 | { allowConstantExport: true }, 18 | ], 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /examples/todo-app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /examples/todo-app/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kyle Mathews 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/todo-app/README.md: -------------------------------------------------------------------------------- 1 | # Todo example 2 | 3 | ## Setup 4 | 5 | 1. Make sure you've installed all dependencies for the monorepo and built packages 6 | 7 | From the root directory: 8 | 9 | - `pnpm i` 10 | - `pnpm run -r build` 11 | 12 | 2. Start the docker containers 13 | 14 | `pnpm run backend:up` 15 | 16 | 3. Start the dev server 17 | 18 | `pnpm run dev` 19 | 20 | 4. When done, tear down the backend containers so you can run other examples 21 | 22 | `pnpm run backend:down` 23 | -------------------------------------------------------------------------------- /examples/todo-app/db/migrations/001-create-todos.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS todos ( 2 | id UUID PRIMARY KEY, 3 | title TEXT NOT NULL, 4 | completed BOOLEAN NOT NULL, 5 | created_at TIMESTAMP WITH TIME ZONE NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /examples/todo-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ElectricSQL Starter 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/todo-app/src/error-page.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteError } from "react-router-dom" 2 | 3 | export default function ErrorPage() { 4 | const error = useRouteError() as Error & { statusText?: string } 5 | console.error(error) 6 | 7 | return ( 8 |
9 |

Oops!

10 |

Sorry, an unexpected error has occurred.

11 |

12 | {error.statusText ?? error.message} 13 |

14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /examples/todo-app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | import { createBrowserRouter, RouterProvider } from "react-router-dom" 4 | import ErrorPage from "./error-page" 5 | import "@radix-ui/themes/styles.css" 6 | import "../public/typography.css" 7 | import { Theme } from "@radix-ui/themes" 8 | import "@fontsource/alegreya-sans/latin.css" 9 | 10 | import Index from "./routes/index" 11 | 12 | // Start example routes 13 | import Root from "./routes/root" 14 | // End example routes 15 | 16 | const router = createBrowserRouter([ 17 | { 18 | path: `/`, 19 | element: , 20 | errorElement: , 21 | children: [ 22 | { 23 | index: true, 24 | element: , 25 | }, 26 | ], 27 | }, 28 | ]) 29 | 30 | async function render() { 31 | ReactDOM.createRoot(document.getElementById(`root`)!).render( 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | render() 41 | -------------------------------------------------------------------------------- /examples/todo-app/src/routes/root.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom" 2 | import { ShapesProvider } from "@electric-sql/react" 3 | 4 | export default function Root() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /examples/todo-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/todo-app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/todo-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import react from "@vitejs/plugin-react-swc" 3 | import { capsizeRadixPlugin } from "vite-plugin-capsize-radix" 4 | import alegreyaSans from "@capsizecss/metrics/alegreyaSans" 5 | import arial from "@capsizecss/metrics/arial" 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | capsizeRadixPlugin({ 11 | // Import this file into your app after you import Radix's CSS. 12 | outputPath: `./public/typography.css`, 13 | // Pass in Capsize font metric objects. 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | defaultFontStack: [alegreyaSans as any, arial as any], 16 | }), 17 | ], 18 | }) 19 | -------------------------------------------------------------------------------- /integration-tests/Makefile: -------------------------------------------------------------------------------- 1 | LUX_BIN="$SCRIPT_DIR/lux/bin/lux" 2 | 3 | lux: 4 | git clone https://github.com/electric-sql/lux.git && \ 5 | cd lux && \ 6 | git checkout otp-27 && \ 7 | autoconf && \ 8 | ./configure && \ 9 | make 10 | -------------------------------------------------------------------------------- /integration-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | We're using [lux](https://github.com/electric-sql/lux/tree/otp-27) to run integration tests that require setting up and orchestrating multiple components. 4 | 5 | To prepare your dev machine for running these tests, run `make` once. 6 | 7 | To execute all tests, run `./run.sh`. 8 | -------------------------------------------------------------------------------- /integration-tests/electric_dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) 6 | 7 | cd "$SCRIPT_DIR"/../packages/sync-service 8 | iex -S mix 9 | -------------------------------------------------------------------------------- /integration-tests/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd) 6 | 7 | LUX_BIN="$SCRIPT_DIR/lux/bin/lux" 8 | LUX="$LUX_BIN --multiplier=${TIMEOUT_MULTIPLIER:-1000}" 9 | 10 | $LUX ${@:-tests/*.lux} 11 | -------------------------------------------------------------------------------- /integration-tests/tests/invalidated-replication-slot.lux: -------------------------------------------------------------------------------- 1 | [doc Verify handling of invalidated replication slot while Electric is running] 2 | 3 | [include replication-slot-invalidation.luxinc] 4 | 5 | [global pg_container_name=replication-slot-invalidated-while-running__pg] 6 | 7 | [my invalidated_slot_error= 8 | """ 9 | [error] GenServer Electric.ConnectionManager terminating 10 | ** (Postgrex.Error) ERROR 55000 (object_not_in_prerequisite_state) cannot read from logical replication slot "electric_slot" 11 | 12 | This slot has been invalidated because it exceeded the maximum reserved size. 13 | """] 14 | 15 | ### 16 | 17 | ## Start a new Postgres cluster configured for easy replication slot invalidation. 18 | [invoke setup_pg \ 19 | "--wal-segsize=1" \ 20 | "-c max_slot_wal_keep_size=1MB -c max_wal_size=2MB"] 21 | 22 | ## Start the sync service. 23 | [invoke setup_electric] 24 | 25 | [shell electric] 26 | ??[info] Starting replication from postgres 27 | 28 | # Reset the failure pattern because we'll be matching on an error. 29 | - 30 | 31 | ## Seed the database with enough data to exceed max_wal_size and force a checkpoint that 32 | ## will invalidate the replication slot. 33 | [invoke seed_pg] 34 | 35 | ## Confirm slot invalidation in Postgres. 36 | [shell pg] 37 | ?invalidating slot "electric_slot" because its restart_lsn \d+/\d+ exceeds max_slot_wal_keep_size 38 | 39 | ## Observe the fatal connection error. 40 | [shell electric] 41 | ??$invalidated_slot_error 42 | 43 | # Confirm Electric process exit. 44 | ??$PS1 45 | 46 | ## Start the sync service once again to verify that it crashes due to the invalidated slot error. 47 | [invoke setup_electric] 48 | 49 | [shell electric] 50 | ??[info] Starting replication from postgres 51 | - 52 | ??$invalidated_slot_error 53 | ??$PS1 54 | 55 | [cleanup] 56 | [invoke teardown] 57 | -------------------------------------------------------------------------------- /integration-tests/tests/replication-slot-invalidation.luxinc: -------------------------------------------------------------------------------- 1 | [global PS1=SH-PROMPT:] 2 | [global fail_pattern=(?i)error|fatal|no such] 3 | 4 | [global pg_container_name=] 5 | [global database_url=] 6 | 7 | [macro setup_pg initdb_args config_opts] 8 | [shell pg] 9 | -$fail_pattern 10 | 11 | !docker run --rm \ 12 | --name $pg_container_name \ 13 | -e POSTGRES_DB=electric \ 14 | -e POSTGRES_USER=postgres \ 15 | -e POSTGRES_PASSWORD=password \ 16 | -e POSTGRES_INITDB_ARGS=${initdb_args} \ 17 | -p 5432 \ 18 | postgres:14-alpine \ 19 | -c wal_level=logical ${config_opts} 20 | 21 | ??database system is ready to accept connections 22 | 23 | # Reset the failure pattern to avoid false failures when Electric tries to create an already 24 | # existing publication or replication slot. 25 | - 26 | 27 | [shell get_container_port] 28 | !docker inspect $pg_container_name --format '{{json .NetworkSettings.Ports}}' 29 | ?"HostIp":"0.0.0.0","HostPort":"(\d+)" 30 | [local port=$1] 31 | [global database_url=postgresql://postgres:password@localhost:$port/postgres?sslmode=disable] 32 | [endmacro] 33 | 34 | [macro seed_pg] 35 | [shell psql] 36 | !docker exec -u postgres -it $pg_container_name psql 37 | 38 | """! 39 | CREATE TABLE items2 ( 40 | id UUID PRIMARY KEY, 41 | val1 TEXT, 42 | val2 TEXT 43 | ); 44 | """ 45 | ??CREATE TABLE 46 | 47 | """! 48 | INSERT INTO 49 | items2 (id, val1, val2) 50 | SELECT 51 | gen_random_uuid(), 52 | '#' || generate_series || ' test val1 ' || repeat('012345679abcdef', 4096), 53 | '#' || generate_series || ' test val2 ' || repeat('012345679abcdef', 4096) 54 | FROM 55 | generate_series(1, 2048); 56 | """ 57 | ??INSERT 0 2048 58 | [endmacro] 59 | 60 | [macro setup_electric] 61 | [shell electric] 62 | -$fail_pattern 63 | 64 | !DATABASE_URL=$database_url ../electric_dev.sh 65 | [endmacro] 66 | 67 | [macro teardown] 68 | -$fail_pattern 69 | 70 | !docker rm -f -v $pg_container_name 71 | [endmacro] 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Electric SQL monorepo", 3 | "private": true, 4 | "dependencies": { 5 | "@changesets/cli": "^2.27.7", 6 | "dotenv-cli": "^7.4.2" 7 | }, 8 | "scripts": { 9 | "example-backend:up": "dotenv -e .env.dev -- docker compose -f ./.support/docker-compose.yml up -d ", 10 | "example-backend:down": "dotenv -e .env.dev -- docker compose -f .support/docker-compose.yml down --volumes", 11 | "stylecheck-all": "pnpm --if-present --recursive run stylecheck", 12 | "ci:version": "pnpm exec changeset version && pnpm exec changeset tag", 13 | "ci:publish": "pnpm publish -r" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/react-hooks/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | 'no-unused-vars': `off`, 25 | '@typescript-eslint/no-unused-vars': [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | ignorePatterns: [ 35 | '**/node_modules/**', 36 | '**/dist/**', 37 | 'tsup.config.ts', 38 | 'vitest.config.ts', 39 | '.eslintrc.js' 40 | ], 41 | } 42 | -------------------------------------------------------------------------------- /packages/react-hooks/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /packages/react-hooks/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/react-hooks/README.md: -------------------------------------------------------------------------------- 1 | # React integration for ElectricSQL 2 | 3 | Electric is Postgres sync for modern apps. 4 | 5 | Electric provides an HTTP interface to Postgres to enable massive number of clients to query and get real-time updates to data in "shapes" i.e. subsets of the database. Electric turns Postgres into a real-time database. 6 | 7 | This packages exposes a `useShape` hook for pulling shape data into your React components. 8 | 9 | The `ShapesProvider` caches shapes globally so re-using shapes in multiple components is cheap. 10 | 11 | ## Install 12 | 13 | `npm i @electricsql/react` 14 | 15 | ## How to use 16 | 17 | Add the Shapes provider 18 | ```tsx 19 | import { ShapesProvider } from "@electric-sql/react" 20 | 21 | ReactDOM.createRoot(document.getElementById(`root`)!).render( 22 | 23 | 24 | 25 | ) 26 | ``` 27 | 28 | Add `useShape` to a component 29 | ``` 30 | import { useShape } from "@electric-sql/react" 31 | 32 | export default function MyComponent () { 33 | const { isUpToDate, data as fooData } = useShape({ 34 | url: "http://my-api.com/shape/foo", 35 | }) 36 | 37 | if (!isUpToDate) { 38 | return
loading
39 | } 40 | 41 | return ( 42 |
43 | {data.map(foo =>
{foo.title}
)} 44 |
45 | ) 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /packages/react-hooks/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './react-hooks' 2 | -------------------------------------------------------------------------------- /packages/react-hooks/test/support/global-setup.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalSetupContext } from 'vitest/node' 2 | import { FetchError } from '@electric-sql/next' 3 | import { makePgClient } from './test-helpers' 4 | 5 | const url = process.env.ELECTRIC_URL ?? `http://localhost:3000` 6 | const proxyUrl = process.env.ELECTRIC_PROXY_CACHE_URL ?? `http://localhost:3002` 7 | 8 | // name of proxy cache container to execute commands against, 9 | // see docker-compose.yml that spins it up for details 10 | const proxyCacheContainerName = `electric_dev-nginx-1` 11 | // path pattern for cache files inside proxy cache to clear 12 | const proxyCachePath = `/var/cache/nginx/*` 13 | 14 | // eslint-disable-next-line quotes -- eslint is acting dumb with enforce backtick quotes mode, and is trying to use it here where it's not allowed. 15 | declare module 'vitest' { 16 | export interface ProvidedContext { 17 | baseUrl: string 18 | proxyCacheBaseUrl: string 19 | testPgSchema: string 20 | proxyCacheContainerName: string 21 | proxyCachePath: string 22 | } 23 | } 24 | 25 | /** 26 | * Global setup for the test suite. Validates that our server is running, and creates and tears down a 27 | * special schema in Postgres to ensure clean slate between runs. 28 | */ 29 | export default async function ({ provide }: GlobalSetupContext) { 30 | const response = await fetch(url) 31 | if (!response.ok) throw FetchError.fromResponse(response, url) 32 | 33 | const client = makePgClient() 34 | await client.connect() 35 | await client.query(`CREATE SCHEMA IF NOT EXISTS electric_test`) 36 | 37 | provide(`baseUrl`, url) 38 | provide(`testPgSchema`, `electric_test`) 39 | provide(`proxyCacheBaseUrl`, proxyUrl) 40 | provide(`proxyCacheContainerName`, proxyCacheContainerName) 41 | provide(`proxyCachePath`, proxyCachePath) 42 | 43 | return async () => { 44 | await client.query(`DROP SCHEMA electric_test`) 45 | await client.end() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-hooks/test/support/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { ShapeStream, Value, Message } from '@electric-sql/next' 2 | import { Client, ClientConfig } from 'pg' 3 | 4 | export function makePgClient(overrides: ClientConfig = {}) { 5 | return new Client({ 6 | host: `localhost`, 7 | port: 54321, 8 | password: `password`, 9 | user: `postgres`, 10 | database: `electric`, 11 | options: `-csearch_path=electric_test`, 12 | ...overrides, 13 | }) 14 | } 15 | 16 | export function forEachMessage( 17 | stream: ShapeStream, 18 | controller: AbortController, 19 | handler: ( 20 | resolve: () => void, 21 | message: Message, 22 | nthDataMessage: number 23 | ) => Promise | void 24 | ) { 25 | return new Promise((resolve, reject) => { 26 | let messageIdx = 0 27 | 28 | stream.subscribe(async (messages) => { 29 | for (const message of messages) { 30 | try { 31 | await handler( 32 | () => { 33 | controller.abort() 34 | return resolve() 35 | }, 36 | message as Message, 37 | messageIdx 38 | ) 39 | if (`action` in message.headers) messageIdx++ 40 | } catch (e) { 41 | controller.abort() 42 | return reject(e) 43 | } 44 | } 45 | }, reject) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-hooks/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["node_modules", "tests", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/react-hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-hooks/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup' 2 | import { defineConfig } from 'tsup' 3 | 4 | export default defineConfig(options => { 5 | const commonOptions: Partial = { 6 | entry: { 7 | index: 'src/index.ts' 8 | }, 9 | tsconfig: `./tsconfig.build.json`, 10 | // esbuildPlugins: [mangleErrorsTransform], 11 | sourcemap: true, 12 | ...options 13 | } 14 | 15 | return [ 16 | // Standard ESM, embedded `process.env.NODE_ENV` checks 17 | { 18 | ...commonOptions, 19 | format: ['esm'], 20 | outExtension: () => ({ js: '.mjs' }), // Add dts: '.d.ts' when egoist/tsup#1053 lands 21 | dts: true, 22 | clean: true 23 | }, 24 | // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension 25 | { 26 | ...commonOptions, 27 | format: ['esm'], 28 | target: 'es2017', 29 | dts: false, 30 | outExtension: () => ({ js: '.js' }), 31 | entry: { 'index.legacy-esm': 'src/index.ts' } as Record 32 | }, 33 | // Browser-ready ESM, production + minified 34 | { 35 | ...commonOptions, 36 | define: { 37 | 'process.env.NODE_ENV': JSON.stringify('production') 38 | }, 39 | format: ['esm'], 40 | outExtension: () => ({ js: '.mjs' }), 41 | minify: true, 42 | entry: { 43 | 'index.browser': 'src/index.ts' 44 | }, 45 | }, 46 | { 47 | ...commonOptions, 48 | format: 'cjs', 49 | outDir: './dist/cjs/', 50 | outExtension: () => ({ js: '.cjs' }) 51 | } 52 | ] 53 | }) 54 | -------------------------------------------------------------------------------- /packages/react-hooks/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globalSetup: `test/support/global-setup.ts`, 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /packages/sync-service/.env.dev: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:password@localhost:54321/electric?sslmode=disable 2 | ENABLE_INTEGRATION_TESTING=true 3 | CACHE_MAX_AGE=1 4 | CACHE_STALE_AGE=3 5 | -------------------------------------------------------------------------------- /packages/sync-service/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:plug] 5 | ] 6 | -------------------------------------------------------------------------------- /packages/sync-service/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | electric-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # The shape database, created when the Sync Service runs 29 | /shapes/ 30 | -------------------------------------------------------------------------------- /packages/sync-service/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ELIXIR_VERSION=1.17.2 2 | ARG OTP_VERSION=27.0.1 3 | ARG DEBIAN_VERSION=bookworm-20240722-slim 4 | 5 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 6 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 7 | 8 | FROM ${BUILDER_IMAGE} AS builder 9 | LABEL maintainer="info@electric-sql.com" 10 | 11 | RUN apt-get update -y && \ 12 | apt-get install -y build-essential git curl && \ 13 | apt-get clean && \ 14 | rm -f /var/lib/apt/lists/*_* 15 | 16 | RUN mix local.hex --force && mix local.rebar --force 17 | 18 | ENV MIX_ENV=prod 19 | 20 | WORKDIR /app 21 | 22 | COPY mix.* /app/ 23 | RUN mix deps.get 24 | RUN mix deps.compile 25 | 26 | # These are ordered by change frequency, with the least frequently changing dir first. 27 | COPY rel /app/rel 28 | COPY lib /app/lib/ 29 | 30 | COPY package.json /app/ 31 | COPY config/*runtime.exs /app/config/ 32 | 33 | ARG ELECTRIC_VERSION=local 34 | RUN MIX_ENV="prod" mix compile 35 | RUN MIX_ENV="prod" mix release 36 | 37 | FROM ${RUNNER_IMAGE} AS runner_setup 38 | 39 | RUN apt-get update -y && \ 40 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates && \ 41 | apt-get clean && \ 42 | rm -f /var/lib/apt/lists/*_* 43 | 44 | # Set the locale 45 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 46 | 47 | ENV LANG=en_US.UTF-8 48 | ENV LANGUAGE=en_US:en 49 | ENV LC_ALL=en_US.UTF-8 50 | 51 | WORKDIR "/app" 52 | RUN chown nobody /app 53 | 54 | FROM runner_setup AS runner 55 | 56 | ARG RELEASE_NAME=electric 57 | ## Vaxine configuration via environment variables 58 | COPY --from=builder /app/_build/prod/rel/${RELEASE_NAME} ./ 59 | RUN mv /app/bin/${RELEASE_NAME} /app/bin/entrypoint 60 | 61 | ENTRYPOINT ["/app/bin/entrypoint"] 62 | CMD ["start"] 63 | -------------------------------------------------------------------------------- /packages/sync-service/README.md: -------------------------------------------------------------------------------- 1 | # Electric 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `electric` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:electric, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | ## Running 23 | 24 | Run Postgres: 25 | 26 | ```sh 27 | docker compose -f dev/docker-compose.yml create 28 | docker compose -f dev/docker-compose.yml start 29 | ``` 30 | 31 | Source the `.env.dev` somehow, e.g.: 32 | 33 | ```sh 34 | set -a; source .env.dev; set +a 35 | ``` 36 | 37 | Run the Elixir app: 38 | 39 | ```sh 40 | mix deps.get 41 | iex -S mix 42 | ``` 43 | -------------------------------------------------------------------------------- /packages/sync-service/coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support", 4 | "lib/electric/replication/eval/known_definition.ex", 5 | "lib/electric.ex", 6 | "lib/electric/telemetry.ex" 7 | ] 8 | } -------------------------------------------------------------------------------- /packages/sync-service/dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | name: "electric_dev" 3 | 4 | services: 5 | postgres: 6 | image: postgres:16-alpine 7 | environment: 8 | POSTGRES_DB: electric 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | ports: 12 | - "54321:5432" 13 | volumes: 14 | - ./postgres.conf:/etc/postgresql.conf:ro 15 | - ./init.sql:/docker-entrypoint-initdb.d/00_shared_init.sql:ro 16 | tmpfs: 17 | - /var/lib/postgresql/data 18 | - /tmp 19 | entrypoint: 20 | - docker-entrypoint.sh 21 | - -c 22 | - config_file=/etc/postgresql.conf 23 | 24 | nginx: 25 | image: nginx:latest 26 | ports: 27 | - "3002:3002" 28 | volumes: 29 | - ./nginx.conf:/etc/nginx/nginx.conf 30 | extra_hosts: 31 | - "host.docker.internal:host-gateway" 32 | depends_on: 33 | - postgres 34 | -------------------------------------------------------------------------------- /packages/sync-service/dev/init.sql: -------------------------------------------------------------------------------- 1 | -- CREATE PUBLICATION electric_publication; -------------------------------------------------------------------------------- /packages/sync-service/dev/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | default_type application/octet-stream; 10 | 11 | # Enable gzip 12 | gzip on; 13 | gzip_types text/plain text/css application/javascript image/svg+xml application/json; 14 | gzip_min_length 1000; 15 | gzip_vary on; 16 | 17 | # Enable caching 18 | proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=60m use_temp_path=off; 19 | 20 | server { 21 | listen 3002; 22 | 23 | location / { 24 | proxy_pass http://host.docker.internal:3000; 25 | proxy_set_header Host $host; 26 | proxy_set_header X-Real-IP $remote_addr; 27 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 28 | proxy_set_header X-Forwarded-Proto $scheme; 29 | 30 | # Enable caching 31 | proxy_cache my_cache; 32 | proxy_cache_revalidate on; 33 | proxy_cache_min_uses 1; 34 | proxy_cache_methods GET HEAD; 35 | proxy_cache_use_stale error timeout; 36 | proxy_cache_background_update on; 37 | proxy_cache_lock on; 38 | 39 | # Add proxy cache status header 40 | add_header X-Proxy-Cache $upstream_cache_status; 41 | add_header X-Cache-Date $upstream_http_date; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/sync-service/dev/postgres.conf: -------------------------------------------------------------------------------- 1 | listen_addresses = '*' 2 | wal_level = logical # minimal, replica, or logical 3 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric do 2 | @doc """ 3 | Every electric cluster belongs to a particular console database instance 4 | 5 | This is that database instance id 6 | """ 7 | @spec instance_id() :: binary | no_return 8 | def instance_id do 9 | Application.fetch_env!(:electric, :instance_id) 10 | end 11 | 12 | @type relation :: {schema :: String.t(), table :: String.t()} 13 | 14 | @current_vsn Mix.Project.config()[:version] 15 | def vsn do 16 | @current_vsn 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/plug/delete_shape_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Plug.DeleteShapePlug do 2 | require Logger 3 | use Plug.Builder 4 | 5 | alias Electric.Shapes 6 | alias Electric.Plug.ServeShapePlug.Params 7 | 8 | plug :fetch_query_params 9 | plug :put_resp_content_type, "application/json" 10 | 11 | plug :allow_shape_deletion 12 | plug :validate_query_params 13 | 14 | plug :truncate_or_delete_shape 15 | 16 | defp allow_shape_deletion(%Plug.Conn{} = conn, _) do 17 | if conn.assigns.config[:allow_shape_deletion] do 18 | conn 19 | else 20 | conn 21 | |> send_resp(404, Jason.encode_to_iodata!(%{status: "Not found"})) 22 | |> halt() 23 | end 24 | end 25 | 26 | defp validate_query_params(%Plug.Conn{} = conn, _) do 27 | all_params = 28 | Map.merge(conn.query_params, conn.path_params) 29 | |> Map.take(["root_table", "shape_id"]) 30 | |> Map.put("offset", "-1") 31 | 32 | case Params.validate(all_params, inspector: conn.assigns.config[:inspector]) do 33 | {:ok, params} -> 34 | %{conn | assigns: Map.merge(conn.assigns, params)} 35 | 36 | {:error, error_map} -> 37 | conn 38 | |> send_resp(400, Jason.encode_to_iodata!(error_map)) 39 | |> halt() 40 | end 41 | end 42 | 43 | defp truncate_or_delete_shape(%Plug.Conn{} = conn, _) do 44 | if conn.assigns.shape_id !== nil do 45 | with :ok <- Shapes.clean_shape(conn.assigns.shape_id, conn.assigns.config) do 46 | send_resp(conn, 202, "") 47 | end 48 | else 49 | # FIXME: This has a race condition where we accidentally create a snapshot & shape id, but clean 50 | # it before snapshot is actually made. 51 | with {shape_id, _} <- 52 | Shapes.get_or_create_shape_id(conn.assigns.shape_definition, conn.assigns.config), 53 | :ok <- Shapes.clean_shape(shape_id, conn.assigns.config) do 54 | send_resp(conn, 202, "") 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/plug/label_process_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Plug.LabelProcessPlug do 2 | @moduledoc """ 3 | A plug that assists debugging by labelling processes that handle requests with 4 | details about the request. 5 | 6 | The plug should be places right after the match plug in the router: 7 | 8 | plug :match 9 | plug Electric.Plug.LabelProcessPlug 10 | """ 11 | 12 | def init(opts), do: opts 13 | 14 | def call(conn, _opts) do 15 | conn 16 | |> process_label() 17 | |> Process.set_label() 18 | 19 | conn 20 | end 21 | 22 | @doc """ 23 | Returns a description of the HTTP request to be used as the lable for the request process. 24 | 25 | ## Examples 26 | 27 | iex> process_label(%{ 28 | ...> method: "GET", 29 | ...> request_path: "/v1/shape/users", 30 | ...> query_string: "offset=-1", 31 | ...> assigns: %{plug_request_id: "F-jPUudNHxbD8lIAABQG"} 32 | ...> }) 33 | "Request F-jPUudNHxbD8lIAABQG - GET /v1/shape/users?offset=-1" 34 | 35 | iex> process_label(%{ 36 | ...> method: "GET", 37 | ...> request_path: "/v1/shape/users", 38 | ...> query_string: "", 39 | ...> assigns: %{plug_request_id: "F-jPUudNHxbD8lIAABQG"} 40 | ...> }) 41 | "Request F-jPUudNHxbD8lIAABQG - GET /v1/shape/users" 42 | """ 43 | def process_label(conn) do 44 | "Request #{conn.assigns.plug_request_id} - #{conn.method} #{conn.request_path}#{query_suffix(conn)}" 45 | end 46 | 47 | defp query_suffix(%{query_string: ""}), do: "" 48 | defp query_suffix(%{query_string: query_string}), do: "?#{query_string}" 49 | end 50 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/plug/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Plug.Router do 2 | use Plug.Router, copy_opts_to_assign: :config 3 | 4 | plug Plug.RequestId, assign_as: :plug_request_id 5 | plug :server_header, Electric.vsn() 6 | plug :match 7 | plug Electric.Plug.LabelProcessPlug 8 | plug Plug.Telemetry, event_prefix: [:electric, :routing] 9 | plug Plug.Logger 10 | plug Plug.RequestId 11 | plug :dispatch 12 | 13 | match "/", via: [:get, :head], do: send_resp(conn, 200, "") 14 | 15 | get "/v1/shape/:root_table", to: Electric.Plug.ServeShapePlug 16 | delete "/v1/shape/:root_table", to: Electric.Plug.DeleteShapePlug 17 | 18 | match _ do 19 | send_resp(conn, 404, "Not found") 20 | end 21 | 22 | def server_header(conn, version) do 23 | conn 24 | |> Plug.Conn.put_resp_header("server", "ElectricSQL/#{version}") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/postgres/inspector.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Postgres.Inspector do 2 | alias Electric.Replication.Eval.Parser 3 | @type relation :: Electric.relation() 4 | 5 | @type column_info :: %{ 6 | name: String.t(), 7 | type: String.t(), 8 | formatted_type: String.t(), 9 | pk_position: non_neg_integer() | nil, 10 | type_id: {typid :: non_neg_integer(), typmod :: integer()} 11 | } 12 | 13 | @callback load_column_info(relation(), opts :: term()) :: 14 | {:ok, [column_info()]} | :table_not_found 15 | 16 | @type inspector :: {module(), opts :: term()} 17 | 18 | @doc """ 19 | Load column information about a given table using a provided inspector. 20 | """ 21 | @spec load_column_info(relation(), inspector()) :: {:ok, [column_info()]} | :table_not_found 22 | def load_column_info(relation, {module, opts}), do: module.load_column_info(relation, opts) 23 | 24 | @doc """ 25 | Get columns that should be considered a PK for table. If the table 26 | has no PK, then we're considering all columns as identifying. 27 | """ 28 | @spec get_pk_cols([column_info(), ...]) :: [String.t(), ...] 29 | def get_pk_cols([_ | _] = columns) do 30 | columns 31 | |> Enum.reject(&is_nil(&1.pk_position)) 32 | |> Enum.sort_by(& &1.pk_position) 33 | |> Enum.map(& &1.name) 34 | |> case do 35 | [] -> Enum.map(columns, & &1.name) 36 | results -> results 37 | end 38 | end 39 | 40 | @doc """ 41 | Convert a column list into something that can be used by 42 | `Electric.Replication.Eval.Parser.parse_and_validate_expression/2` 43 | """ 44 | @spec columns_to_expr([column_info(), ...]) :: Parser.refs_map() 45 | def columns_to_expr(columns) when is_list(columns) do 46 | Map.new(columns, fn %{name: name, type: type} -> {[name], String.to_atom(type)} end) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/postgres/inspector/direct_inspector.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Postgres.Inspector.DirectInspector do 2 | @behaviour Electric.Postgres.Inspector 3 | 4 | @doc """ 5 | Load table information (refs) from the database 6 | """ 7 | def load_column_info({namespace, tbl}, conn) do 8 | query = """ 9 | SELECT 10 | attname as name, 11 | (atttypid, atttypmod) as type_id, 12 | attndims as array_dimensions, 13 | atttypmod as type_mod, 14 | pg_type.typname as type, 15 | elem_pg_type.typname as array_type, -- type of the element inside the array or nil if it's not an array 16 | format_type(pg_attribute.atttypid, pg_attribute.atttypmod) AS formatted_type, 17 | array_position(indkey, attnum) as pk_position 18 | FROM pg_class 19 | JOIN pg_namespace ON relnamespace = pg_namespace.oid 20 | JOIN pg_attribute ON attrelid = pg_class.oid AND attnum >= 0 21 | JOIN pg_type ON atttypid = pg_type.oid 22 | LEFT JOIN pg_index ON indrelid = pg_class.oid AND indisprimary 23 | LEFT JOIN pg_type AS elem_pg_type ON pg_type.typelem = elem_pg_type.oid 24 | WHERE relname = $1 AND nspname = $2 AND relkind = 'r' 25 | ORDER BY pg_class.oid, attnum 26 | """ 27 | 28 | result = Postgrex.query!(conn, query, [tbl, namespace]) 29 | 30 | if Enum.empty?(result.rows) do 31 | :table_not_found 32 | else 33 | columns = Enum.map(result.columns, &String.to_atom/1) 34 | rows = Enum.map(result.rows, fn row -> Enum.zip(columns, row) |> Map.new() end) 35 | {:ok, rows} 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/replication/eval/expr.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Replication.Eval.Expr do 2 | @moduledoc """ 3 | Parsed expression, available for evaluation using the runner 4 | """ 5 | 6 | alias Electric.Replication.Eval.Env 7 | 8 | defstruct [:query, :eval, :used_refs, :returns] 9 | 10 | @type used_refs :: %{required([String.t(), ...]) => Env.pg_type()} 11 | 12 | @type t() :: %__MODULE__{ 13 | query: String.t(), 14 | eval: term(), 15 | used_refs: used_refs(), 16 | returns: Env.pg_type() 17 | } 18 | end 19 | -------------------------------------------------------------------------------- /packages/sync-service/lib/electric/shapes/querying.ex: -------------------------------------------------------------------------------- 1 | defmodule Electric.Shapes.Querying do 2 | alias Electric.Utils 3 | alias Electric.Shapes.Shape 4 | 5 | @type row :: [term()] 6 | 7 | @spec stream_initial_data(DBConnection.t(), Shape.t()) :: 8 | {Postgrex.Query.t(), Enumerable.t(row())} 9 | def stream_initial_data(conn, %Shape{root_table: root_table, table_info: table_info} = shape) do 10 | table = Utils.relation_to_sql(root_table) 11 | 12 | where = 13 | if not is_nil(shape.where), do: " WHERE " <> shape.where.query, else: "" 14 | 15 | query = 16 | Postgrex.prepare!( 17 | conn, 18 | table, 19 | ~s|SELECT #{columns(table_info, root_table)} FROM #{table} #{where}| 20 | ) 21 | 22 | stream = 23 | Postgrex.stream(conn, query, []) 24 | |> Stream.flat_map(& &1.rows) 25 | 26 | {query, stream} 27 | end 28 | 29 | defp columns(table_info, root_table) do 30 | table_info 31 | |> Map.fetch!(root_table) 32 | |> Map.fetch!(:columns) 33 | |> Enum.map(&~s("#{Utils.escape_quotes(&1.name)}"::text)) 34 | |> Enum.join(", ") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /packages/sync-service/lib/pg_interop/postgrex/extensions/pg_lsn.ex: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Postgrex.Extensions.PgLsn do 2 | use Postgrex.BinaryExtension, send: "pg_lsn_send" 3 | import Postgrex.BinaryUtils, warn: false 4 | 5 | def encode(_state) do 6 | quote location: :keep do 7 | %Electric.Postgres.Lsn{} = lsn -> 8 | <<8::int32(), Electric.Postgres.Lsn.to_integer(lsn)::uint64()>> 9 | 10 | other -> 11 | raise DBConnection.EncodeError, 12 | Postgrex.Utils.encode_msg(other, "a value of type Electric.Postgres.Lsn.t()") 13 | end 14 | end 15 | 16 | def decode(_) do 17 | quote location: :keep do 18 | <<8::int32(), wal_offset::uint64()>> -> 19 | Electric.Postgres.Lsn.from_integer(wal_offset) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /packages/sync-service/lib/pg_interop/postgrex/types.ex: -------------------------------------------------------------------------------- 1 | Postgrex.Types.define(PgInterop.Postgrex.Types, [PgInterop.Postgrex.Extensions.PgLsn]) 2 | -------------------------------------------------------------------------------- /packages/sync-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@core/sync-service", 3 | "private": true, 4 | "version": "0.2.5" 5 | } -------------------------------------------------------------------------------- /packages/sync-service/rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Adjust Erlang logger and SASL config to get a more or less sane behaviour for opentelemetry. 4 | # Without these options, opentelemetry does not emit any logs when running in a release. 5 | # Without the SASL options, logs would get spammed with PROGRESS REPORT messages. 6 | export ELIXIR_ERL_OPTIONS="-kernel logger_sasl_compatible true -sasl sasl_error_logger false -kernel logger_level info" 7 | -------------------------------------------------------------------------------- /packages/sync-service/rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | # Use the default number of ports regardless of the `OPEN_MAX` limit configured in the OS. 2 | # 3 | # In recent versions of Linux kernel and/or Docker, the OPEN_MAX limit is set to an 4 | # astronomically high value of 1073741816. Erlang picks that value to set its maximum number of 5 | # ports. When the number is so high, the memory taken up by the port tables ends up dominating 6 | # the total memory usage of the VM, and it doesn't even show up in Observer stats. 7 | # 8 | # Anecdotally, I have seen the memory usage of a Docker container running an Electric release 9 | # doing nothing go from 1.8GB down to 176MB with this setting. 10 | # 11 | # See also: 12 | # - https://elixirforum.com/t/elixir-erlang-docker-containers-ram-usage-on-different-oss-kernels 13 | # - https://elixirforum.com/t/beam-vm-consumes-big-amounts-of-memory-virt-vs-erlang-memory-total 14 | +Q 1024 15 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Electric.Config, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/plug/label_process_plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Plug.LabelProcessPlugTest do 2 | use ExUnit.Case, async: true 3 | doctest Electric.Plug.LabelProcessPlug, import: true 4 | end 5 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/postgres/inspector/ets_inspector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Postgres.Inspector.EtsInspectorTest do 2 | use Support.TransactionCase, async: true 3 | import Support.ComponentSetup 4 | import Support.DbStructureSetup 5 | alias Electric.Postgres.Inspector.EtsInspector 6 | 7 | describe "load_column_info/2" do 8 | setup [:with_inspector, :with_basic_tables] 9 | 10 | setup %{tables: [table | _], inspector: {EtsInspector, opts}} do 11 | {:ok, %{opts: opts, table: table}} 12 | end 13 | 14 | test "returns column info for the table", %{opts: opts, table: table} do 15 | assert {:ok, [%{name: "id"}, %{name: "value"}]} = EtsInspector.load_column_info(table, opts) 16 | end 17 | 18 | test "returns same value from ETS cache as the original call", %{opts: opts, table: table} do 19 | original = EtsInspector.load_column_info(table, opts) 20 | from_cache = EtsInspector.load_column_info(table, opts) 21 | assert from_cache == original 22 | end 23 | 24 | test "returns same value from ETS cache as the original call with concurrent calls", %{ 25 | opts: opts, 26 | table: table 27 | } do 28 | task = Task.async(fn -> EtsInspector.load_column_info(table, opts) end) 29 | original = EtsInspector.load_column_info(table, opts) 30 | from_cache = Task.await(task) 31 | assert from_cache == original 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/postgres/lsn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Postgres.LsnTest do 2 | alias Electric.Postgres.Lsn 3 | use ExUnit.Case, async: true 4 | 5 | doctest Electric.Postgres.Lsn, import: true 6 | 7 | test "LSN implements `Inspect` protocol" do 8 | assert inspect(%Lsn{segment: 5}) == "#Lsn<5/0>" 9 | end 10 | 11 | test "LSN implement `String.Chars` protocol" do 12 | assert to_string(%Lsn{segment: 5}) == "5/0" 13 | end 14 | 15 | test "LSN implement `List.Chars` protocol" do 16 | assert to_charlist(%Lsn{segment: 5}) == ~c"5/0" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/postgres_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.PostgresTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Electric.Postgres, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/replication/changes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Replication.ChangesTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Electric.Replication.Changes.NewRecord 5 | alias Electric.Replication.Changes.UpdatedRecord 6 | alias Electric.Replication.Changes.DeletedRecord 7 | 8 | doctest Electric.Replication.Changes, import: true 9 | 10 | describe "UpdatedRecord.changed_columns" do 11 | test "is empty when old_record is nil" do 12 | changed_columns = MapSet.new([]) 13 | 14 | assert %UpdatedRecord{changed_columns: ^changed_columns} = 15 | UpdatedRecord.new(old_record: nil, record: %{"this" => "that"}) 16 | end 17 | 18 | test "captures column if new value != old value" do 19 | changed_columns = MapSet.new(["first"]) 20 | 21 | assert %UpdatedRecord{changed_columns: ^changed_columns} = 22 | UpdatedRecord.new( 23 | old_record: %{"first" => "first value", "second" => "second value"}, 24 | record: %{"first" => "updated first value", "second" => "second value"} 25 | ) 26 | end 27 | 28 | test "captures column if old record does not have column value" do 29 | changed_columns = MapSet.new(["first", "second"]) 30 | 31 | assert %UpdatedRecord{changed_columns: ^changed_columns} = 32 | UpdatedRecord.new( 33 | old_record: %{"first" => "first value"}, 34 | record: %{"first" => "updated first value", "second" => "second value"} 35 | ) 36 | end 37 | 38 | test "ignores column if new does not have value" do 39 | changed_columns = MapSet.new(["second"]) 40 | 41 | assert %UpdatedRecord{changed_columns: ^changed_columns} = 42 | UpdatedRecord.new( 43 | old_record: %{"first" => "first value", "second" => "second value"}, 44 | record: %{"second" => "second updated value"} 45 | ) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/replication/eval/env/basic_types_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Replication.Eval.Env.BasicTypesTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Electric.Replication.Eval.Env.BasicTypes, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/replication/log_offset_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Replication.LogOffsetTest do 2 | alias Electric.Postgres.Lsn 3 | alias Electric.Replication.LogOffset 4 | 5 | use ExUnit.Case, async: true 6 | 7 | doctest Electric.Replication.LogOffset, import: true 8 | 9 | test "LogOffset initializes as 0,0" do 10 | assert %LogOffset{} == LogOffset.first() 11 | assert %{tx_offset: 0} = %LogOffset{} 12 | assert %{op_offset: 0} = %LogOffset{} 13 | end 14 | 15 | test "LogOffset implements `Inspect` protocol" do 16 | assert inspect(LogOffset.new(0, 0)) == "LogOffset.new(0, 0)" 17 | assert inspect(LogOffset.new(10, 2)) == "LogOffset.new(10, 2)" 18 | assert inspect(LogOffset.before_all()) == "LogOffset.before_all()" 19 | end 20 | 21 | test "LogOffset implements `Json.Encoder` protocol" do 22 | assert {:ok, "\"0_0\""} = Jason.encode(LogOffset.new(0, 0)) 23 | assert {:ok, "\"10_2\""} = Jason.encode(LogOffset.new(10, 2)) 24 | assert {:ok, "\"-1\""} = Jason.encode(LogOffset.before_all()) 25 | end 26 | 27 | test "LogOffset implements `String.Chars` protocol" do 28 | assert to_string(LogOffset.new(0, 0)) == "0_0" 29 | assert to_string(LogOffset.new(10, 2)) == "10_2" 30 | assert to_string(LogOffset.before_all()) == "-1" 31 | end 32 | 33 | test "LogOffset implements `List.Chars` protocol" do 34 | assert to_charlist(LogOffset.new(0, 0)) == ~c"0_0" 35 | assert to_charlist(LogOffset.new(10, 2)) == ~c"10_2" 36 | assert to_charlist(LogOffset.before_all()) == ~c"-1" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/replication/postgres_interop/casting_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.Replication.PostgresInterop.CastingTest do 2 | use ExUnit.Case, async: true 3 | doctest Electric.Replication.PostgresInterop.Casting, import: true 4 | end 5 | -------------------------------------------------------------------------------- /packages/sync-service/test/electric/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Electric.UtilsTest do 2 | alias Electric.Utils 3 | use ExUnit.Case, async: true 4 | doctest Utils, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/interval/iso8601_alternative_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Interval.ISO8601AlternativeParserTest do 2 | use ExUnit.Case, async: true 3 | alias PgInterop.Interval 4 | doctest PgInterop.Interval.ISO8601AlternativeParser, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/interval/iso8601_formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Interval.Iso8601FormatterTest do 2 | use ExUnit.Case, async: true 3 | alias PgInterop.Interval 4 | doctest PgInterop.Interval.Iso8601Formatter, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/interval/iso8601_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Interval.ISO8601ParserTest do 2 | use ExUnit.Case, async: true 3 | alias PgInterop.Interval 4 | doctest PgInterop.Interval.ISO8601Parser, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/interval/postgres_and_sql_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Interval.PostgresAndSQLParserTest do 2 | use ExUnit.Case, async: true 3 | alias PgInterop.Interval 4 | doctest PgInterop.Interval.PostgresAndSQLParser, import: true 5 | end 6 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/interval_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.IntervalTest do 2 | use ExUnit.Case, async: true 3 | alias PgInterop.Interval 4 | doctest PgInterop.Interval, import: true 5 | 6 | test "Interval implements Inspect protocol" do 7 | assert inspect(Interval.parse!("P1YT10H")) == ~S|Interval.parse!("P1YT10H")| 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /packages/sync-service/test/pg_interop/postgrex/extensions/pg_lsn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PgInterop.Postgrex.Extensions.PgLsnTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Electric.Postgres.Lsn 5 | 6 | setup {Support.DbSetup, :with_unique_db} 7 | 8 | test "can decode pg_lsn values", %{db_conn: conn} do 9 | {:ok, result} = Postgrex.query(conn, "SELECT '0/0'::pg_lsn", []) 10 | assert %Postgrex.Result{rows: [[lsn]], num_rows: 1} = result 11 | assert Lsn.from_string("0/0") == lsn 12 | 13 | {:ok, result} = Postgrex.query(conn, "SELECT '2BDC54/6291F4B1'::pg_lsn", []) 14 | assert %Postgrex.Result{rows: [[lsn]], num_rows: 1} = result 15 | assert Lsn.from_integer(12_34_56_78_9_87_65_43_21) == lsn 16 | end 17 | 18 | test "can encode pg_lsn values", %{db_conn: conn} do 19 | lsn1 = Lsn.from_string("1/0") 20 | lsn2 = Lsn.from_string("0/0") 21 | {:ok, result} = Postgrex.query(conn, "SELECT $1::pg_lsn - $2", [lsn1, lsn2]) 22 | assert %Postgrex.Result{rows: [[lsn_diff]], num_rows: 1} = result 23 | assert :math.pow(2, 32) == Decimal.to_float(lsn_diff) 24 | end 25 | 26 | test "raises on invalid values", %{db_conn: conn} do 27 | for val <- [4445, "0/0", Decimal.new("12345")] do 28 | error_msg = 29 | "Postgrex expected a value of type Electric.Postgres.Lsn.t(), got #{inspect(val)}. " <> 30 | "Please make sure the value you are passing matches the definition in your table " <> 31 | "or in your query or convert the value accordingly." 32 | 33 | assert_raise DBConnection.EncodeError, error_msg, fn -> 34 | Postgrex.query(conn, "SELECT $1::pg_lsn", [val]) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /packages/sync-service/test/support/db_structure_setup.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.DbStructureSetup do 2 | def with_basic_tables(%{db_conn: conn} = context) do 3 | Postgrex.query!( 4 | conn, 5 | """ 6 | CREATE TABLE items ( 7 | id UUID PRIMARY KEY, 8 | value TEXT NOT NULL 9 | #{additional_fields(context)} 10 | ) 11 | """, 12 | [] 13 | ) 14 | 15 | {:ok, tables: [{"public", "items"}]} 16 | end 17 | 18 | def with_sql_execute(%{db_conn: conn, with_sql: sql}) do 19 | {:ok, results} = 20 | Postgrex.transaction(conn, fn conn -> 21 | sql 22 | |> List.wrap() 23 | |> Enum.map(fn 24 | stmt when is_binary(stmt) -> Postgrex.query!(conn, stmt, []) 25 | {stmt, params} -> Postgrex.query!(conn, stmt, params) 26 | end) 27 | end) 28 | 29 | {:ok, %{sql_execute: results}} 30 | end 31 | 32 | def with_sql_execute(_), do: :ok 33 | 34 | defp additional_fields(%{additional_fields: additional_fields}), do: ", " <> additional_fields 35 | defp additional_fields(_), do: nil 36 | end 37 | -------------------------------------------------------------------------------- /packages/sync-service/test/support/stub_inspector.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.StubInspector do 2 | @behaviour Electric.Postgres.Inspector 3 | 4 | def new(opts), do: {__MODULE__, opts} 5 | 6 | @impl true 7 | def load_column_info(_relation, column_list) when is_list(column_list) do 8 | column_list 9 | |> Enum.map(fn column -> 10 | column 11 | |> Map.put_new(:pk_position, nil) 12 | |> Map.put_new(:type, "text") 13 | end) 14 | |> then(&{:ok, &1}) 15 | end 16 | 17 | def load_column_info(relation, opts) when is_map(opts) and is_map_key(opts, relation) do 18 | opts 19 | |> Map.fetch!(relation) 20 | |> then(&load_column_info(relation, &1)) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /packages/sync-service/test/support/test_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.TestUtils do 2 | alias Electric.ShapeCache.Storage 3 | alias Electric.Replication.Changes 4 | 5 | @doc """ 6 | Preprocess a list of `Changes.data_change()` structs in the same way they 7 | are preprocessed before reaching storage. 8 | """ 9 | def preprocess_changes(changes, pk \\ ["id"], xid \\ 1) do 10 | changes 11 | |> Enum.map(&Changes.fill_key(&1, pk)) 12 | |> Enum.flat_map(&Storage.prepare_change_for_storage(&1, xid, pk)) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /packages/sync-service/test/support/transaction_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.TransactionCase do 2 | @moduledoc """ 3 | Special test case that starts a DB connection, and runs entire test in 4 | a single Postgrex transaction, rolling it back completely after the test 5 | has ended. 6 | 7 | Exposes a context variable `conn` to run queries over. 8 | """ 9 | use ExUnit.CaseTemplate 10 | import Support.DbSetup 11 | 12 | setup_all :with_shared_db 13 | setup :in_transaction 14 | end 15 | -------------------------------------------------------------------------------- /packages/sync-service/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mox.defmock(Electric.ShapeCache.MockStorage, for: Electric.ShapeCache.Storage) 2 | Mox.defmock(Electric.ShapeCacheMock, for: Electric.ShapeCacheBehaviour) 3 | 4 | ExUnit.start(assert_receive_timeout: 400) 5 | -------------------------------------------------------------------------------- /packages/typescript-client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | `eslint:recommended`, 9 | `plugin:@typescript-eslint/recommended`, 10 | `plugin:prettier/recommended`, 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | requireConfigFile: false, 15 | sourceType: `module`, 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | parser: `@typescript-eslint/parser`, 21 | plugins: [`prettier`], 22 | rules: { 23 | quotes: [`error`, `backtick`], 24 | 'no-unused-vars': `off`, 25 | '@typescript-eslint/no-unused-vars': [ 26 | `error`, 27 | { 28 | argsIgnorePattern: `^_`, 29 | varsIgnorePattern: `^_`, 30 | caughtErrorsIgnorePattern: `^_`, 31 | }, 32 | ], 33 | }, 34 | ignorePatterns: [ 35 | '**/node_modules/**', 36 | '**/dist/**', 37 | 'tsup.config.ts', 38 | 'vitest.config.ts', 39 | '.eslintrc.js' 40 | ], 41 | } 42 | -------------------------------------------------------------------------------- /packages/typescript-client/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /packages/typescript-client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/typescript-client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @electric-sql/next 2 | 3 | ## 0.2.2 4 | 5 | ### Patch Changes 6 | 7 | - 06e843c: Only include schema in header of responses to non-live requests. 8 | - 22f388f: Parse float4 into a JS Number in the JS ShapeStream abstraction. 9 | 10 | ## 0.2.1 11 | 12 | ### Patch Changes 13 | 14 | - 5c43a31: Parse values of basic types (int2, int4, int8, float8, bool, json/jsonb) and arrays of those types into JS values on the client. 15 | 16 | ## 0.2.0 17 | 18 | ### Minor Changes 19 | 20 | - 1ca40a7: feat: refactor ShapeStream API to combine and to better support API proxies 21 | 22 | ## 0.1.1 23 | 24 | ### Patch Changes 25 | 26 | - c3aafda: fix: add prepack script so typescript gets compiled before publishing 27 | 28 | ## 0.1.0 29 | 30 | ### Minor Changes 31 | 32 | - 36b9ab5: Update the client to work correctly with patch (instead of full) updates 33 | 34 | ## 0.0.8 35 | 36 | ### Patch Changes 37 | 38 | - fedf95c: fix: make packaging work in Remix, etc. 39 | 40 | ## 0.0.7 41 | 42 | ### Patch Changes 43 | 44 | - 4ce7634: useShape now uses useSyncExternalStoreWithSelector for better integration with React's rendering lifecycle 45 | 46 | ## 0.0.6 47 | 48 | ### Patch Changes 49 | 50 | - 324effc: Updated typescript-client README and docs page. 51 | 52 | ## 0.0.5 53 | 54 | ### Patch Changes 55 | 56 | - 7208887: Fix `fetch` not being bound correctly 57 | 58 | ## 0.0.4 59 | 60 | ### Patch Changes 61 | 62 | - 958cc0c: Respect 409 errors by restarting the stream with the new `shape_id`. 63 | 64 | ## 0.0.3 65 | 66 | ### Patch Changes 67 | 68 | - af3452a: Fix empty initial requests leading to infinite loop of empty live requests. 69 | - cf3b3bb: Updated package author, license and homepage. 70 | - 6fdb1b2: chore: updated testing fixtures 71 | 72 | ## 0.0.2 73 | 74 | ### Patch Changes 75 | 76 | - 3656959: Fixed publishing to include built code 77 | -------------------------------------------------------------------------------- /packages/typescript-client/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /packages/typescript-client/test/support/global-setup.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalSetupContext } from 'vitest/node' 2 | import { FetchError } from '../../src/client' 3 | import { makePgClient } from './test-helpers' 4 | 5 | const url = process.env.ELECTRIC_URL ?? `http://localhost:3000` 6 | const proxyUrl = process.env.ELECTRIC_PROXY_CACHE_URL ?? `http://localhost:3002` 7 | 8 | // name of proxy cache container to execute commands against, 9 | // see docker-compose.yml that spins it up for details 10 | const proxyCacheContainerName = `electric_dev-nginx-1` 11 | // path pattern for cache files inside proxy cache to clear 12 | const proxyCachePath = `/var/cache/nginx/*` 13 | 14 | // eslint-disable-next-line quotes -- eslint is acting dumb with enforce backtick quotes mode, and is trying to use it here where it's not allowed. 15 | declare module 'vitest' { 16 | export interface ProvidedContext { 17 | baseUrl: string 18 | proxyCacheBaseUrl: string 19 | testPgSchema: string 20 | proxyCacheContainerName: string 21 | proxyCachePath: string 22 | } 23 | } 24 | 25 | /** 26 | * Global setup for the test suite. Validates that our server is running, and creates and tears down a 27 | * special schema in Postgres to ensure clean slate between runs. 28 | */ 29 | export default async function ({ provide }: GlobalSetupContext) { 30 | const response = await fetch(url) 31 | if (!response.ok) throw FetchError.fromResponse(response, url) 32 | 33 | const client = makePgClient() 34 | await client.connect() 35 | await client.query(`CREATE SCHEMA IF NOT EXISTS electric_test`) 36 | 37 | provide(`baseUrl`, url) 38 | provide(`testPgSchema`, `electric_test`) 39 | provide(`proxyCacheBaseUrl`, proxyUrl) 40 | provide(`proxyCacheContainerName`, proxyCacheContainerName) 41 | provide(`proxyCachePath`, proxyCachePath) 42 | 43 | return async () => { 44 | await client.query(`DROP SCHEMA electric_test`) 45 | await client.end() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/typescript-client/test/support/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { ShapeStream } from '../../src/client' 2 | import { Client, ClientConfig } from 'pg' 3 | import { Value, Message } from '../../src/types' 4 | 5 | export function makePgClient(overrides: ClientConfig = {}) { 6 | return new Client({ 7 | host: `localhost`, 8 | port: 54321, 9 | password: `password`, 10 | user: `postgres`, 11 | database: `electric`, 12 | options: `-csearch_path=electric_test`, 13 | ...overrides, 14 | }) 15 | } 16 | 17 | export function forEachMessage( 18 | stream: ShapeStream, 19 | controller: AbortController, 20 | handler: ( 21 | resolve: () => void, 22 | message: Message, 23 | nthDataMessage: number 24 | ) => Promise | void 25 | ) { 26 | return new Promise((resolve, reject) => { 27 | let messageIdx = 0 28 | 29 | stream.subscribe(async (messages) => { 30 | for (const message of messages) { 31 | try { 32 | await handler( 33 | () => { 34 | controller.abort() 35 | return resolve() 36 | }, 37 | message as Message, 38 | messageIdx 39 | ) 40 | if (`action` in message.headers) messageIdx++ 41 | } catch (e) { 42 | controller.abort() 43 | return reject(e) 44 | } 45 | } 46 | }, reject) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /packages/typescript-client/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["node_modules", "tests", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/typescript-client/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'tsup' 2 | import { defineConfig } from 'tsup' 3 | 4 | export default defineConfig(options => { 5 | const commonOptions: Partial = { 6 | entry: { 7 | index: 'src/index.ts' 8 | }, 9 | tsconfig: `./tsconfig.build.json`, 10 | sourcemap: true, 11 | ...options 12 | } 13 | 14 | return [ 15 | // Standard ESM, embedded `process.env.NODE_ENV` checks 16 | { 17 | ...commonOptions, 18 | format: ['esm'], 19 | outExtension: () => ({ js: '.mjs' }), // Add dts: '.d.ts' when egoist/tsup#1053 lands 20 | dts: true, 21 | clean: true 22 | }, 23 | // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension 24 | { 25 | ...commonOptions, 26 | format: ['esm'], 27 | target: 'es2017', 28 | dts: false, 29 | outExtension: () => ({ js: '.js' }), 30 | entry: { 'index.legacy-esm': 'src/index.ts' } 31 | }, 32 | // Browser-ready ESM, production + minified 33 | { 34 | ...commonOptions, 35 | entry: { 36 | 'index.browser': 'src/index.ts' 37 | }, 38 | define: { 39 | 'process.env.NODE_ENV': JSON.stringify('production') 40 | }, 41 | format: ['esm'], 42 | outExtension: () => ({ js: '.mjs' }), 43 | minify: true 44 | }, 45 | { 46 | ...commonOptions, 47 | format: 'cjs', 48 | outDir: './dist/cjs/', 49 | outExtension: () => ({ js: '.cjs' }) 50 | } 51 | ] 52 | }) 53 | -------------------------------------------------------------------------------- /packages/typescript-client/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globalSetup: `test/support/global-setup.ts`, 6 | }, 7 | }) 8 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'examples/*' 4 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": false, 4 | "baseUrl": "./", 5 | "target": "es2016", 6 | "lib": [ 7 | "ESNext" 8 | ], 9 | "jsx": "preserve", 10 | "noEmit": true, 11 | "module": "preserve", 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "moduleResolution": "bundler" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": false, 4 | "baseUrl": "./", 5 | "target": "es2016", 6 | "noEmit": true, 7 | "jsx": "preserve", 8 | "module": "preserve", 9 | "moduleResolution": "bundler" 10 | } 11 | } 12 | --------------------------------------------------------------------------------