diff --git a/examples/encryption/.eslintignore b/examples/encryption/.eslintignore
new file mode 100644
index 0000000000..e32a3e1834
--- /dev/null
+++ b/examples/encryption/.eslintignore
@@ -0,0 +1 @@
+/build/**
diff --git a/examples/encryption/.eslintrc.cjs b/examples/encryption/.eslintrc.cjs
new file mode 100644
index 0000000000..b0858622eb
--- /dev/null
+++ b/examples/encryption/.eslintrc.cjs
@@ -0,0 +1,41 @@
+module.exports = {
+ env: {
+ browser: true,
+ es2021: true,
+ node: true,
+ },
+ extends: [
+ `eslint:recommended`,
+ `plugin:@typescript-eslint/recommended`,
+ `plugin:prettier/recommended`,
+ ],
+ parserOptions: {
+ ecmaVersion: 2022,
+ requireConfigFile: false,
+ sourceType: `module`,
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ parser: `@typescript-eslint/parser`,
+ plugins: [`prettier`],
+ rules: {
+ quotes: [`error`, `single`],
+ "no-unused-vars": `off`,
+ "@typescript-eslint/no-unused-vars": [
+ `error`,
+ {
+ argsIgnorePattern: `^_`,
+ varsIgnorePattern: `^_`,
+ caughtErrorsIgnorePattern: `^_`,
+ },
+ ],
+ },
+ ignorePatterns: [
+ `**/node_modules/**`,
+ `**/dist/**`,
+ `tsup.config.ts`,
+ `vitest.config.ts`,
+ `.eslintrc.js`,
+ ],
+};
diff --git a/examples/encryption/.gitignore b/examples/encryption/.gitignore
new file mode 100644
index 0000000000..9829edff9a
--- /dev/null
+++ b/examples/encryption/.gitignore
@@ -0,0 +1,2 @@
+dist
+.env.local
diff --git a/examples/encryption/.prettierrc b/examples/encryption/.prettierrc
new file mode 100644
index 0000000000..ad4d895523
--- /dev/null
+++ b/examples/encryption/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/examples/encryption/README.md b/examples/encryption/README.md
new file mode 100644
index 0000000000..41db4cf653
--- /dev/null
+++ b/examples/encryption/README.md
@@ -0,0 +1,64 @@
+
+# Encryption example
+
+This is an example of encryption with Electric. It's a React app with a very simple Express API server.
+
+The Electric-specific code is in [`./src/Example.tsx`](./src/Example.tsx). It demonstrates:
+
+- encrypting data before sending to the API server
+- decrypting data after it syncs in through Electric
+
+## Setup
+
+This example is part of the [ElectricSQL monorepo](../..) and is designed to be built and run as part of the [pnpm workspace](https://pnpm.io/workspaces) defined in [`../../pnpm-workspace.yaml`](../../pnpm-workspace.yaml).
+
+Navigate to the root directory of the monorepo, e.g.:
+
+```shell
+cd ../../
+```
+
+Install and build all of the workspace packages and examples:
+
+```shell
+pnpm install
+pnpm run -r build
+```
+
+Navigate back to this directory:
+
+```shell
+cd examples/basic-example
+```
+
+Start the example backend services using [Docker Compose](https://docs.docker.com/compose/):
+
+```shell
+pnpm backend:up
+```
+
+Now start the dev server:
+
+```shell
+pnpm dev
+```
+
+Open [localhost:5173]http://localhost:5173] in your web browser. When you add items, the plaintext is encrypted before it leaves the app. You can see the ciphertext in Postgres, e.g.:
+
+```console
+$ psql "postgresql://postgres:password@localhost:54321/electric"
+psql (16.4)
+Type "help" for help.
+
+electric=# select * from items;
+ id | ciphertext | iv
+--------------------------------------+------------------------------+------------------
+ 491b2654-5714-48bb-a206-59f87a2dc33c | vDwv3IX5AGXJVi2jNJJDPE25MwiS | 0gwdqHvqiJ8lJqaS
+(1 row)
+```
+
+When you're done, stop the backend services using:
+
+```shell
+pnpm backend:down
+```
\ No newline at end of file
diff --git a/examples/encryption/backend/api.js b/examples/encryption/backend/api.js
new file mode 100644
index 0000000000..9ff630f84e
--- /dev/null
+++ b/examples/encryption/backend/api.js
@@ -0,0 +1,70 @@
+import bodyParser from 'body-parser'
+import cors from 'cors'
+import express from 'express'
+import pg from 'pg'
+
+import { z } from 'zod'
+
+// Connect to Postgres.
+const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:54321/electric'
+const DATABASE_USE_SSL = process.env.DATABASE_USE_SSL === 'true' || false
+const pool = new pg.Pool({connectionString: DATABASE_URL, ssl: DATABASE_USE_SSL})
+const db = await pool.connect()
+
+// Expose an HTTP server.
+const PORT = parseInt(process.env.PORT || '3001')
+const app = express()
+app.use(bodyParser.json())
+app.use(cors())
+
+// Validate user input
+const createSchema = z.object({
+ id: z.string().uuid(),
+ ciphertext: z.string(),
+ iv: z.string()
+})
+
+// Expose `POST {data} /items`.
+app.post(`/items`, async (req, res) => {
+ let data
+ try {
+ data = createSchema.parse(req.body)
+ }
+ catch (err) {
+ return res.status(400).json({ errors: err.errors })
+ }
+
+ // Insert the item into the database.
+ const sql = `
+ INSERT INTO items (
+ id,
+ ciphertext,
+ iv
+ )
+ VALUES (
+ $1,
+ $2,
+ $3
+ )
+ `
+
+ const params = [
+ data.id,
+ data.ciphertext,
+ data.iv
+ ]
+
+ try {
+ await db.query(sql, params)
+ }
+ catch (err) {
+ return res.status(500).json({ errors: err })
+ }
+
+ return res.status(200).json({ status: 'OK' })
+})
+
+// Start the server
+app.listen(PORT, () => {
+ console.log(`Server listening at http://localhost:${PORT}`)
+})
diff --git a/examples/encryption/db/migrations/01-create_items_table.sql b/examples/encryption/db/migrations/01-create_items_table.sql
new file mode 100644
index 0000000000..e04b61407e
--- /dev/null
+++ b/examples/encryption/db/migrations/01-create_items_table.sql
@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS items (
+ id UUID PRIMARY KEY NOT NULL,
+ ciphertext TEXT NOT NULL,
+ iv TEXT NOT NULL
+);
diff --git a/examples/encryption/index.html b/examples/encryption/index.html
new file mode 100644
index 0000000000..f69b26596f
--- /dev/null
+++ b/examples/encryption/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Web Example - ElectricSQL
+
+
+
+
+
+
diff --git a/examples/encryption/package.json b/examples/encryption/package.json
new file mode 100644
index 0000000000..e84a37bc31
--- /dev/null
+++ b/examples/encryption/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@electric-examples/encryption",
+ "private": true,
+ "version": "0.0.1",
+ "author": "ElectricSQL",
+ "license": "Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "backend:up": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:up && pnpm db:migrate",
+ "backend:down": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:down",
+ "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations",
+ "dev": "concurrently \"vite\" \"node backend/api.js\"",
+ "build": "vite build",
+ "format": "eslint . --ext ts,tsx --fix",
+ "stylecheck": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@electric-sql/react": "workspace:*",
+ "base64-js": "^1.5.1",
+ "body-parser": "^1.20.2",
+ "cors": "^2.8.5",
+ "express": "^4.19.2",
+ "pg": "^8.12.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "uuid": "^10.0.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@databases/pg-migrations": "^5.0.3",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@types/uuid": "^10.0.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "concurrently": "^8.2.2",
+ "dotenv": "^16.4.5",
+ "eslint": "^8.57.0",
+ "typescript": "^5.5.3",
+ "vite": "^5.3.4"
+ }
+}
diff --git a/examples/encryption/public/favicon.ico b/examples/encryption/public/favicon.ico
new file mode 100644
index 0000000000..55765b6d89
Binary files /dev/null and b/examples/encryption/public/favicon.ico differ
diff --git a/examples/encryption/public/robots.txt b/examples/encryption/public/robots.txt
new file mode 100644
index 0000000000..e9e57dc4d4
--- /dev/null
+++ b/examples/encryption/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/examples/encryption/src/App.css b/examples/encryption/src/App.css
new file mode 100644
index 0000000000..6b041c8403
--- /dev/null
+++ b/examples/encryption/src/App.css
@@ -0,0 +1,25 @@
+.App {
+ text-align: center;
+}
+
+.App-logo {
+ height: 64px;
+ pointer-events: none;
+ margin-top: min(40px, 5vmin);
+ margin-bottom: min(20px, 4vmin);
+}
+
+.App-header {
+ background-color: #1c1e20;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: top;
+ justify-content: top;
+ font-size: calc(10px + 2vmin);
+ color: white;
+}
+
+.App-link {
+ color: #61dafb;
+}
diff --git a/examples/encryption/src/App.tsx b/examples/encryption/src/App.tsx
new file mode 100644
index 0000000000..cd3eab5029
--- /dev/null
+++ b/examples/encryption/src/App.tsx
@@ -0,0 +1,16 @@
+import logo from './assets/logo.svg'
+import './App.css'
+import './style.css'
+
+import { Example } from './Example'
+
+export default function App() {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/examples/encryption/src/Example.css b/examples/encryption/src/Example.css
new file mode 100644
index 0000000000..e7fbf3c7aa
--- /dev/null
+++ b/examples/encryption/src/Example.css
@@ -0,0 +1,79 @@
+.controls {
+ margin-bottom: 1.5rem;
+}
+
+.button {
+ display: inline-block;
+ line-height: 1.3;
+ text-align: center;
+ text-decoration: none;
+ vertical-align: middle;
+ cursor: pointer;
+ user-select: none;
+ width: calc(15vw + 100px);
+ margin-right: 0.5rem !important;
+ margin-left: 0.5rem !important;
+ border-radius: 32px;
+ text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4);
+ box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px;
+ background: #1e2123;
+ border: 2px solid #229089;
+ color: #f9fdff;
+ font-size: 16px;
+ font-weight: 500;
+ padding: 10px 18px;
+}
+
+.item {
+ display: block;
+ line-height: 1.3;
+ text-align: center;
+ vertical-align: middle;
+ width: calc(30vw - 1.5rem + 200px);
+ margin-right: auto;
+ margin-left: auto;
+ border-radius: 9px;
+ border: 1px solid #D0BCFF;
+ background: #1e2123;
+ color: #D0BCFF;
+ font-size: 13px;
+ padding: 10px 18px;
+}
+
+form {
+ border-top: 0.5px solid rgba(227, 227, 239, 0.32);
+ width: calc(30vw - 1.5rem + 200px);
+ margin: 20px auto;
+ padding: 20px 0;
+}
+
+form input[type=text] {
+ padding: 12px 18px;
+ margin-bottom: 18px;
+ background: #1e2123;
+ border: 1px solid rgba(227, 227, 239, 0.92);
+ border-radius: 9px;
+ color: #f5f5f5;
+ outline: none;
+ font-size: 14px;
+ display: block;
+ text-align: center;
+ width: calc(30vw - 1.5rem + 160px);
+ margin-right: auto;
+ margin-left: auto;
+}
+
+form input[type=text]::placeholder {
+ color: rgba(227, 227, 239, 0.62);
+}
+
+form button[type=submit] {
+ background: #D0BCFF;
+ border: none;
+ padding: 8px 20px;
+ border-radius: 9px;
+ color: #1e2123;
+ font-size: 15px;
+ font-weight: 500;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/examples/encryption/src/Example.tsx b/examples/encryption/src/Example.tsx
new file mode 100644
index 0000000000..53c171bb27
--- /dev/null
+++ b/examples/encryption/src/Example.tsx
@@ -0,0 +1,168 @@
+import base64 from 'base64-js'
+import React, { useEffect, useState } from 'react'
+import { useShape } from '@electric-sql/react'
+import './Example.css'
+
+type Item = {
+ id: string
+ title: string
+}
+
+type EncryptedItem = {
+ id: string
+ ciphertext: string
+ iv: string
+}
+
+const API_URL = import.meta.env.API_URL || 'http://localhost:3001'
+const ELECTRIC_URL = import.meta.env.ELECTRIC_URL ?? 'http://localhost:3000'
+
+// For this example, we hardcode a deterministic key that works across page loads.
+// In a real app, you would implement a key management strategy. Electric is great
+// at syncing keys between users :)
+const rawKey = new Uint8Array(16)
+const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [
+ 'encrypt',
+ 'decrypt',
+])
+
+/*
+ * Encrypt an `Item` into an `EncryptedItem`.
+ */
+async function encrypt(item: Item): Promise {
+ const { id, title } = item
+
+ const enc = new TextEncoder()
+ const encoded = enc.encode(title)
+ const iv = crypto.getRandomValues(new Uint8Array(12))
+
+ const encrypted = await crypto.subtle.encrypt(
+ {
+ iv,
+ name: 'AES-GCM',
+ },
+ key,
+ encoded
+ )
+
+ const ciphertext = base64.fromByteArray(new Uint8Array(encrypted))
+ const iv_str = base64.fromByteArray(iv)
+
+ return {
+ id,
+ ciphertext,
+ iv: iv_str,
+ }
+}
+
+/*
+ * Decrypt an `EncryptedItem` to an `Item`.
+ */
+async function decrypt(item: EncryptedItem): Promise- {
+ const { id, ciphertext, iv: iv_str } = item
+
+ const encrypted = base64.toByteArray(ciphertext)
+ const iv = base64.toByteArray(iv_str)
+
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ iv,
+ name: 'AES-GCM',
+ },
+ key,
+ encrypted
+ )
+
+ const dec = new TextDecoder()
+ const title = dec.decode(decrypted)
+
+ return {
+ id,
+ title,
+ }
+}
+
+export const Example = () => {
+ const [items, setItems] = useState
- ()
+
+ const { data } = useShape({
+ url: `${ELECTRIC_URL}/v1/shape`,
+ params: {
+ table: 'items',
+ },
+ })
+
+ const rows = data !== undefined ? data : []
+
+ // There are more efficient ways of updating state than always decrypting
+ // all the items on any change but just to demonstate the decryption ...
+ useEffect(() => {
+ async function init() {
+ const items = await Promise.all(
+ rows.map(async (row) => await decrypt(row))
+ )
+
+ setItems(items)
+ }
+
+ init()
+ }, [rows])
+
+ /*
+ * Handle adding an item by creating the item data, encrypting it
+ * and sending it to the API
+ */
+ async function createItem(event: React.FormEvent) {
+ event.preventDefault()
+
+ const form = event.target as HTMLFormElement
+ const formData = new FormData(form)
+ const title = formData.get('title') as string
+
+ const id = crypto.randomUUID()
+ const item = {
+ id,
+ title,
+ }
+
+ const data = await encrypt(item)
+
+ const url = `${API_URL}/items`
+ const options = {
+ method: 'POST',
+ body: JSON.stringify(data),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+
+ await fetch(url, options)
+
+ form.reset()
+ }
+
+ if (items === undefined) {
+ return
Loading...
+ }
+
+ return (
+
+
+ {items.map((item: Item, index: number) => (
+
+ {item.title}
+
+ ))}
+
+
+
+ )
+}
diff --git a/examples/encryption/src/assets/logo.svg b/examples/encryption/src/assets/logo.svg
new file mode 100644
index 0000000000..9d122343d7
--- /dev/null
+++ b/examples/encryption/src/assets/logo.svg
@@ -0,0 +1,11 @@
+
diff --git a/examples/encryption/src/main.tsx b/examples/encryption/src/main.tsx
new file mode 100644
index 0000000000..2a6077e907
--- /dev/null
+++ b/examples/encryption/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './style.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/examples/encryption/src/style.css b/examples/encryption/src/style.css
new file mode 100644
index 0000000000..82edb15a5d
--- /dev/null
+++ b/examples/encryption/src/style.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ font-family: 'Helvetica Neue', Helvetica, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background: #1c1e20;
+ min-width: 360px;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
\ No newline at end of file
diff --git a/examples/encryption/src/vite-env.d.ts b/examples/encryption/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/encryption/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/encryption/tsconfig.json b/examples/encryption/tsconfig.json
new file mode 100644
index 0000000000..5710471917
--- /dev/null
+++ b/examples/encryption/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/encryption/vite.config.ts b/examples/encryption/vite.config.ts
new file mode 100644
index 0000000000..b2e7564e33
--- /dev/null
+++ b/examples/encryption/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ build: {
+ target: 'esnext',
+ },
+ plugins: [react()],
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 27d5d77da2..40755304df 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -54,6 +54,70 @@ importers:
specifier: ^5.3.4
version: 5.4.10(@types/node@20.17.6)
+ examples/encryption:
+ dependencies:
+ '@electric-sql/react':
+ specifier: workspace:*
+ version: link:../../packages/react-hooks
+ base64-js:
+ specifier: ^1.5.1
+ version: 1.5.1
+ body-parser:
+ specifier: ^1.20.2
+ version: 1.20.3
+ cors:
+ specifier: ^2.8.5
+ version: 2.8.5
+ express:
+ specifier: ^4.19.2
+ version: 4.21.1
+ pg:
+ specifier: ^8.12.0
+ version: 8.13.1
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ uuid:
+ specifier: ^10.0.0
+ version: 10.0.0
+ zod:
+ specifier: ^3.23.8
+ version: 3.23.8
+ devDependencies:
+ '@databases/pg-migrations':
+ specifier: ^5.0.3
+ version: 5.0.3(typescript@5.6.3)
+ '@types/react':
+ specifier: ^18.3.3
+ version: 18.3.12
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.1
+ '@types/uuid':
+ specifier: ^10.0.0
+ version: 10.0.0
+ '@vitejs/plugin-react':
+ specifier: ^4.3.1
+ version: 4.3.3(vite@5.4.10(@types/node@20.17.6))
+ concurrently:
+ specifier: ^8.2.2
+ version: 8.2.2
+ dotenv:
+ specifier: ^16.4.5
+ version: 16.4.5
+ eslint:
+ specifier: ^8.57.0
+ version: 8.57.1
+ typescript:
+ specifier: ^5.5.3
+ version: 5.6.3
+ vite:
+ specifier: ^5.3.4
+ version: 5.4.10(@types/node@20.17.6)
+
examples/gatekeeper-auth:
dependencies:
'@electric-sql/client':
@@ -7631,13 +7695,8 @@ packages:
tsx@4.19.2:
resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==}
- dependencies:
- esbuild: 0.23.1
- get-tsconfig: 4.8.1
engines: {node: '>=18.0.0'}
hasBin: true
- optionalDependencies:
- fsevents: 2.3.3
turbo-stream@2.4.0:
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
@@ -15812,7 +15871,12 @@ snapshots:
- tsx
- yaml
- tsx@4.19.2: {}
+ tsx@4.19.2:
+ dependencies:
+ esbuild: 0.23.1
+ get-tsconfig: 4.8.1
+ optionalDependencies:
+ fsevents: 2.3.3
turbo-stream@2.4.0: {}