├── .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 |
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 |
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 |
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 |
24 | Add
25 |
26 |
27 | Clear
28 |
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 | setShowItems(!showItems)}
18 | >
19 |
20 | {title}
21 |
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 |
26 | {children}
27 |
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 | {
31 | setIsOpen(false)
32 | if (onDismiss) onDismiss()
33 | }}
34 | >
35 | Cancel
36 |
37 |
41 | Delete Issue
42 |
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 |
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 |
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 |
--------------------------------------------------------------------------------