From b9cfa1e1fd66efad079b316af729ca264346109b Mon Sep 17 00:00:00 2001 From: Ana Bujan Date: Mon, 2 Dec 2024 07:20:10 +0100 Subject: [PATCH] add web support - sqlite, indexeddb, asyncstorage --- .eslintignore | 1 + .eslintrc.js | 6 +- README.md | 29 +++- app.config.ts | 35 +++-- app/_layout.tsx | 57 ++++--- app/detail/[id].tsx | 20 ++- app/index.tsx | 6 +- babel.config.js | 2 +- migrations/001_initial.ts | 9 +- migrations/002_populate.ts | 2 +- package-lock.json | 146 +++++++++++++++++- package.json | 9 +- preview-web.png | Bin 0 -> 32973 bytes src/DbMigrationRunner.spec.ts | 2 +- src/DbMigrationRunner.ts | 4 +- src/SQLiteProvider.tsx | 50 +----- src/data/DataContext.ts | 16 ++ src/data/providers/AppDataProvider.tsx | 34 ++++ src/data/providers/IndexedDBDataProvider.tsx | 41 +++++ .../providers/LocalStorageDataProvider.tsx | 9 ++ src/data/providers/SQLiteDataProvider.tsx | 32 ++++ src/data/sqliteDatabase.ts | 1 + src/data/sqliteDatabase.web.ts | 57 +++++++ src/data/types.ts | 18 +++ src/taskClient/IndexedDBClient.ts | 43 ++++++ src/taskClient/LocalStorageTaskClient.ts | 45 ++++++ .../SQLiteTaskClient.spec.ts} | 8 +- .../SQLiteTaskClient.ts} | 9 +- src/taskClient/types.ts | 8 + src/types.ts | 2 +- 30 files changed, 580 insertions(+), 121 deletions(-) create mode 100644 .eslintignore create mode 100644 preview-web.png create mode 100644 src/data/DataContext.ts create mode 100644 src/data/providers/AppDataProvider.tsx create mode 100644 src/data/providers/IndexedDBDataProvider.tsx create mode 100644 src/data/providers/LocalStorageDataProvider.tsx create mode 100644 src/data/providers/SQLiteDataProvider.tsx create mode 100644 src/data/sqliteDatabase.ts create mode 100644 src/data/sqliteDatabase.web.ts create mode 100644 src/data/types.ts create mode 100644 src/taskClient/IndexedDBClient.ts create mode 100644 src/taskClient/LocalStorageTaskClient.ts rename src/{TaskClient.spec.ts => taskClient/SQLiteTaskClient.spec.ts} (75%) rename src/{TaskClient.ts => taskClient/SQLiteTaskClient.ts} (83%) create mode 100644 src/taskClient/types.ts diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..01f3e99 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +expo-env.d.ts diff --git a/.eslintrc.js b/.eslintrc.js index 1f3f232..7eb1cf3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,7 +3,7 @@ module.exports = { extends: ['expo', 'prettier', 'plugin:prettier/recommended'], plugins: ['prettier'], rules: { - "eslint-comments/no-unlimited-disable": 0, - "prettier/prettier": ["error"], - } + 'eslint-comments/no-unlimited-disable': 0, + 'prettier/prettier': ['error'], + }, }; diff --git a/README.md b/README.md index bcc15c2..774335d 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,21 @@ # React Native SQLite Expo Demo A demo application built with Expo and React Native, showcasing the integration of SQLite -using expo-sqlite within an expo-router project. This app serves as an example of: -- Database SQLite Migration -- Integration Testing: Run integration tests for expo-sqlite in a Node.js environment. -The app functions as a simple "CRUD" (Create, Read, Delete) tasks manager. +using expo-sqlite within an expo-router project. +Works natively and on the web. +# Chapters +## 1. Database migrations and integration testing +I made first version of the app to showcase how you can to database migrations +and configure integration tests to be run in a Node.js environment. Read more about it in my blog post on [expo sqlite migrations and integration testing](https://www.amarjanica.com/bridging-the-gap-between-expo-sqlite-and-node-js/) or [watch my YT tutorial](https://youtu.be/5OBi4JtlGfY). +[Codebase](https://github.com/amarjanica/react-native-sqlite-expo-demo/tree/98c355d5b1fa065a5ec6585273232908edfe50ec) + +## 2. Web support with SQLite, AsyncStorage and IndexedDB +I've added web support to the app, so it can run on the web. You can dynamically switch between +different storage types: SQLite, AsyncStorage and IndexedDB. SQLite is supported on the web via +[sql.js](https://github.com/sql-js/sql.js/). # App Screenshot

@@ -15,7 +23,7 @@ or [watch my YT tutorial](https://youtu.be/5OBi4JtlGfY).

# Run it on Android -I've tested this demo only on android emulator. +I've tested this demo natively on android emulator. ```sh npm i # runs on expo go @@ -24,5 +32,16 @@ npm run go:android npm run dev:android ``` +# Web App Screenshot +

+Web app Screenshot example +

+ +# Run it on the web +```sh +npm i +npm run web +``` + # Tests Tests don't need an emulator. They're just `jest` tests that you can run with `npm test` like any nodejs project. diff --git a/app.config.ts b/app.config.ts index c13975a..3c70c64 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,31 +1,34 @@ /* eslint-env node */ -import { ExpoConfig } from "@expo/config-types"; +import { ExpoConfig } from '@expo/config-types'; const config: ExpoConfig = { - jsEngine: "hermes", - scheme: "demo", - name: "react-native-sqlite-expo-demo", - slug: "react-native-sqlite-expo-demo", - version: "1.0.0", - orientation: "portrait", - icon: "./assets/icon.png", - userInterfaceStyle: "light", + jsEngine: 'hermes', + scheme: 'demo', + name: 'react-native-sqlite-expo-demo', + slug: 'react-native-sqlite-expo-demo', + version: '1.0.0', + orientation: 'portrait', + icon: './assets/icon.png', + userInterfaceStyle: 'light', splash: { - image: "./assets/splash.png", - resizeMode: "contain", - backgroundColor: "#ffffff", + image: './assets/splash.png', + resizeMode: 'contain', + backgroundColor: '#ffffff', + }, + extra: { + dbName: 'test', }, ios: { supportsTablet: true, }, android: { - package: "com.amarjanica.reactnativesqliteexpodemo", + package: 'com.amarjanica.reactnativesqliteexpodemo', adaptiveIcon: { - foregroundImage: "./assets/adaptive-icon.png", - backgroundColor: "#ffffff", + foregroundImage: './assets/adaptive-icon.png', + backgroundColor: '#ffffff', }, }, - plugins: ["expo-router"], + plugins: ['expo-router'], experiments: { typedRoutes: true, }, diff --git a/app/_layout.tsx b/app/_layout.tsx index f400719..e212e24 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,40 +1,50 @@ import { Slot } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native'; +import { Button, KeyboardAvoidingView, Platform, StyleSheet, View, Text } from 'react-native'; import React from 'react'; -import migrations from '../migrations'; -import DbMigrationRunner from '@/DbMigrationRunner'; -import logger from '@/logger'; -import { SQLiteDatabase } from 'expo-sqlite'; -import SQLiteProvider from '@/SQLiteProvider'; +import AppDataProvider from '@/data/providers/AppDataProvider'; +import { PersistenceType } from '@/data/types'; const Root = () => { - const [ready, setReady] = React.useState(false); - const migrateDbIfNeeded = async (db: SQLiteDatabase) => { - try { - await new DbMigrationRunner(db).apply(migrations); - logger.log('All migrations applied.'); - setReady(true); - } catch (err) { - logger.error(err); - } - }; + const [persistenceType, setPersistenceType] = React.useState( + Platform.select({ web: PersistenceType.indexedDB, default: PersistenceType.sqlite }) + ); + return ( - - {ready && } - + + + Persistence type: {persistenceType}, OS: {Platform.OS} + + + + + + + + ); }; const styles = StyleSheet.create({ + buttons: { + flexDirection: 'row', + justifyContent: 'space-around', + alignSelf: 'center', + width: '50%', + padding: 10, + }, container: { flex: 1, backgroundColor: '#f8f8f8', @@ -42,6 +52,11 @@ const styles = StyleSheet.create({ keyboardView: { flex: 1, }, + title: { + textAlign: 'center', + fontSize: 20, + padding: 10, + }, }); export default Root; diff --git a/app/detail/[id].tsx b/app/detail/[id].tsx index 90e5ae6..e0e8353 100644 --- a/app/detail/[id].tsx +++ b/app/detail/[id].tsx @@ -1,34 +1,38 @@ import { router, useLocalSearchParams } from 'expo-router'; -import TaskClient from '@/TaskClient'; import React, { useState } from 'react'; import { Task } from '@/types'; import { Unmatched } from 'expo-router'; import { Button, StyleSheet, Text, View } from 'react-native'; import logger from '@/logger'; -import { useSQLiteContext } from '@/SQLiteProvider'; +import { useDataContext } from '@/data/DataContext'; const Page = () => { const { id } = useLocalSearchParams<{ id: string }>(); - const ctx = useSQLiteContext(); - const client = new TaskClient(ctx); + const decodedId = parseInt(id); + const { tasksClient: client } = useDataContext(); const [task, setTask] = useState(null); const [ready, setReady] = useState(false); const goBack = () => { - router.back(); + if (router.canGoBack()) { + router.back(); + } else { + router.push('/'); + } }; + const handleDelete = async () => { - await client.delete(parseInt(id)); + await client.delete(decodedId); goBack(); }; React.useEffect(() => { const prepare = async () => { - setTask(await client.task(id)); + setTask(await client.task(decodedId)); }; logger.log('prepare detail'); prepare().finally(() => setReady(true)); - }, [id]); + }, [client, decodedId]); if (!ready) { return false; diff --git a/app/index.tsx b/app/index.tsx index b6206ce..1bd9c90 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,16 +1,14 @@ import { FlatList, Text, TextInput, TouchableOpacity, View } from 'react-native'; import React, { useState } from 'react'; -import { useSQLiteContext } from '@/SQLiteProvider'; -import TaskClient from '@/TaskClient'; import { Task } from '@/types'; import logger from '@/logger'; import type { ListRenderItem } from '@react-native/virtualized-lists'; import { router } from 'expo-router'; import globalStyles from '@/globalStyles'; +import { useDataContext } from '@/data/DataContext'; const LandingPage = () => { - const ctx = useSQLiteContext(); - const client = new TaskClient(ctx); + const { tasksClient: client } = useDataContext(); const [tasks, setTasks] = useState([]); const [newTask, setNewTask] = useState(''); diff --git a/babel.config.js b/babel.config.js index 2900afe..9d89e13 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,4 @@ -module.exports = function(api) { +module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], diff --git a/migrations/001_initial.ts b/migrations/001_initial.ts index afa9c48..86afeae 100644 --- a/migrations/001_initial.ts +++ b/migrations/001_initial.ts @@ -1,4 +1,4 @@ -import { SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; import { DatabaseMigration } from '@/types'; const migration: DatabaseMigration = { @@ -6,12 +6,11 @@ const migration: DatabaseMigration = { async up(db: SQLiteDatabase): Promise { await db.execAsync(` CREATE TABLE task ( - id INTEGER PRIMARY KEY, - task TEXT NOT NULL, + id INTEGER PRIMARY KEY, + task TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP -); -`); +);`); }, }; diff --git a/migrations/002_populate.ts b/migrations/002_populate.ts index 1545cfd..e251cac 100644 --- a/migrations/002_populate.ts +++ b/migrations/002_populate.ts @@ -1,4 +1,4 @@ -import { SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; import { DatabaseMigration } from '@/types'; const migration: DatabaseMigration = { diff --git a/package-lock.json b/package-lock.json index 59d01a1..6365fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "react-native-sqlite-expo-demo", "version": "1.0.0", "dependencies": { + "@expo/metro-runtime": "~3.2.3", + "@react-native-async-storage/async-storage": "^2.1.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "expo": "^51.0.34", @@ -16,16 +18,21 @@ "expo-router": "~3.5.23", "expo-sqlite": "~14.0.6", "expo-status-bar": "~1.12.1", + "idb": "^8.0.0", "prettier": "^3.3.3", "react": "18.2.0", + "react-dom": "18.2.0", "react-native": "0.74.5", "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.31.1" + "react-native-screens": "3.31.1", + "react-native-web": "~0.19.10", + "sql.js": "^1.12.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@types/jest": "^29.5.13", "@types/react": "~18.2.79", + "@types/sql.js": "^1.4.9", "eslint": "^8.57.0", "eslint-config-expo": "^7.1.2", "jest": "^29.7.0", @@ -4595,6 +4602,17 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.1.0.tgz", + "integrity": "sha512-eAGQGPTAuFNEoIQSB5j2Jh1zm5NPyBRTfjRMfCN0W1OakC5WIB5vsDyIQhUweKN9XOE2/V07lqTMGsL0dGXNkA==", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-community/cli": { "version": "13.6.9", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz", @@ -6913,6 +6931,12 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "node_modules/@types/emscripten": { + "version": "1.39.13", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.13.tgz", + "integrity": "sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -7039,6 +7063,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/sql.js": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.9.tgz", + "integrity": "sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==", + "dev": true, + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -9004,6 +9038,14 @@ "node": ">=8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -10927,6 +10969,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-loops": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz", + "integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==" + }, "node_modules/fast-uri": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", @@ -11761,6 +11808,11 @@ "ms": "^2.0.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -11773,6 +11825,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11965,6 +12022,15 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/inline-style-prefixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz", + "integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==", + "dependencies": { + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" + } + }, "node_modules/internal-ip": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz", @@ -12380,6 +12446,14 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -16103,6 +16177,17 @@ "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -17736,6 +17821,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/pouchdb-collections": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pouchdb-collections/-/pouchdb-collections-1.0.1.tgz", @@ -18158,6 +18248,26 @@ } } }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -18273,6 +18383,30 @@ "react-native": "*" } }, + "node_modules/react-native-web": { + "version": "0.19.13", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", + "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^6.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/react-native/node_modules/@react-native/normalize-colors": { "version": "0.74.87", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.87.tgz", @@ -19346,6 +19480,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/sql.js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.12.0.tgz", + "integrity": "sha512-Bi+43yMx/tUFZVYD4AUscmdL6NHn3gYQ+CM+YheFWLftOmrEC/Mz6Yh7E96Y2WDHYz3COSqT+LP6Z79zgrwJlA==" + }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -19746,6 +19885,11 @@ "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==" + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", diff --git a/package.json b/package.json index fbb180b..27368b0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test": "jest" }, "dependencies": { + "@expo/metro-runtime": "~3.2.3", + "@react-native-async-storage/async-storage": "^2.1.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "expo": "^51.0.34", @@ -21,16 +23,21 @@ "expo-router": "~3.5.23", "expo-sqlite": "~14.0.6", "expo-status-bar": "~1.12.1", + "idb": "^8.0.0", "prettier": "^3.3.3", "react": "18.2.0", + "react-dom": "18.2.0", "react-native": "0.74.5", "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.31.1" + "react-native-screens": "3.31.1", + "react-native-web": "~0.19.10", + "sql.js": "^1.12.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@types/jest": "^29.5.13", "@types/react": "~18.2.79", + "@types/sql.js": "^1.4.9", "eslint": "^8.57.0", "eslint-config-expo": "^7.1.2", "jest": "^29.7.0", diff --git a/preview-web.png b/preview-web.png new file mode 100644 index 0000000000000000000000000000000000000000..bf2f85932713dbec5432dbe5dbd6f8a8bf1b736a GIT binary patch literal 32973 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 literal 0 HcmV?d00001 diff --git a/src/DbMigrationRunner.spec.ts b/src/DbMigrationRunner.spec.ts index bcb0fb3..2fc8013 100644 --- a/src/DbMigrationRunner.spec.ts +++ b/src/DbMigrationRunner.spec.ts @@ -1,4 +1,4 @@ -import { openDatabaseAsync, SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteDatabase, openDatabaseAsync } from '@/data/sqliteDatabase'; import DbMigrationRunner from '@/DbMigrationRunner'; import migrations from '../migrations'; diff --git a/src/DbMigrationRunner.ts b/src/DbMigrationRunner.ts index 5705a00..6d01089 100644 --- a/src/DbMigrationRunner.ts +++ b/src/DbMigrationRunner.ts @@ -1,4 +1,4 @@ -import { SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; import { DatabaseMigration, UserVersion } from '@/types'; import logger from '@/logger'; @@ -33,7 +33,7 @@ class DbMigrationRunner { logger.log(`Executing ${currentMigration.name}`); await currentMigration.up(this.db); } catch (error) { - throw new Error(`Could not execute migration`, { cause: error }); + throw new Error(`Could not execute migration ${currentMigration.name}`, { cause: error }); } }); return userVersion + index + 1; diff --git a/src/SQLiteProvider.tsx b/src/SQLiteProvider.tsx index e9bf004..47cf616 100644 --- a/src/SQLiteProvider.tsx +++ b/src/SQLiteProvider.tsx @@ -1,54 +1,18 @@ -import { openDatabaseAsync, SQLiteDatabase, SQLiteOpenOptions } from 'expo-sqlite'; -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useEffect, useState } from 'react'; import logger from '@/logger'; - -export interface SQLiteProviderAssetSource { - /** - * The asset ID returned from the `require()` call. - */ - assetId: number; - - /** - * Force overwrite the local database file even if it already exists. - * @default false - */ - forceOverwrite?: boolean; -} - -export interface SQLiteProviderProps { - /** - * The name of the database file to open. - */ - databaseName: string; - - /** - * Open options. - */ - options?: SQLiteOpenOptions; - assetSource?: SQLiteProviderAssetSource; - children: React.ReactNode; - onError?: (error: Error) => void; - useSuspense?: boolean; -} +import { SQLiteDatabase, openDatabaseAsync } from '@/data/sqliteDatabase'; /** * Create a context for the SQLite database */ const SQLiteContext = createContext(null); - -export function useSQLiteContext(): SQLiteDatabase { - const context = useContext(SQLiteContext); - if (context == null) { - throw new Error('useSQLiteContext must be used within a '); - } - return context; -} - function SQLiteProvider({ databaseName, children, onInit, -}: Pick & { +}: { + databaseName: string; + children: React.ReactNode; onInit: (db: SQLiteDatabase) => Promise; }) { const [db, setDb] = useState(null); @@ -76,7 +40,7 @@ function SQLiteProvider({ } }; - setup(); + void setup(); return () => { isMounting.current = true; @@ -93,4 +57,4 @@ function SQLiteProvider({ return isFullyLoaded && {children}; } -export default SQLiteProvider +export default SQLiteProvider; diff --git a/src/data/DataContext.ts b/src/data/DataContext.ts new file mode 100644 index 0000000..732931e --- /dev/null +++ b/src/data/DataContext.ts @@ -0,0 +1,16 @@ +import { TaskClient } from '@/taskClient/types'; +import React from 'react'; + +type Value = { + tasksClient: TaskClient; +}; + +export const DataContext = React.createContext(null); + +export const useDataContext = () => { + const context = React.useContext(DataContext); + if (!context) { + throw new Error('useDataContext must be used within a DataProvider'); + } + return context; +}; diff --git a/src/data/providers/AppDataProvider.tsx b/src/data/providers/AppDataProvider.tsx new file mode 100644 index 0000000..f93a4c4 --- /dev/null +++ b/src/data/providers/AppDataProvider.tsx @@ -0,0 +1,34 @@ +import { DataProviderProps, PersistenceType } from '@/data/types'; +import React from 'react'; +import LocalStorageDataProvider from '@/data/providers/LocalStorageDataProvider'; +import SQLiteDataProvider from '@/data/providers/SQLiteDataProvider'; +import { DataContext } from '@/data/DataContext'; +import IndexedDBDataProvider from '@/data/providers/IndexedDBDataProvider'; + +const PersistenceProviderWrapper: React.FC = ({ + persistenceType, + ...props +}) => { + const Component: React.FC = { + [PersistenceType.sqlite]: SQLiteDataProvider, + [PersistenceType.indexedDB]: IndexedDBDataProvider, + [PersistenceType.localstorage]: LocalStorageDataProvider, + }[persistenceType]; + + return ; +}; + +const AppDataProvider: React.FC<{ + children: React.ReactNode; + persistenceType: PersistenceType; +}> = ({ children, persistenceType }) => { + return ( + + {(props) => { + return {children}; + }} + + ); +}; + +export default AppDataProvider; diff --git a/src/data/providers/IndexedDBDataProvider.tsx b/src/data/providers/IndexedDBDataProvider.tsx new file mode 100644 index 0000000..48b9a5a --- /dev/null +++ b/src/data/providers/IndexedDBDataProvider.tsx @@ -0,0 +1,41 @@ +import { DataProviderProps, IndexedDBSchema } from '@/data/types'; +import React from 'react'; +import IndexedDBClient from '@/taskClient/IndexedDBClient'; +import { IDBPDatabase, openDB } from 'idb'; +import logger from '@/logger'; + +const IndexedDBDataProvider: React.FC = ({ children }) => { + const [db, setDb] = React.useState | null>(null); + + React.useEffect(() => { + const dbVersion = 1; + const dbName = 'demodb'; + const initializeDatabase = async () => { + try { + setDb( + await openDB(dbName, dbVersion, { + upgrade(db) { + if (!db.objectStoreNames.contains('tasks')) { + db.createObjectStore('tasks', { + keyPath: 'id', + autoIncrement: true, + }); + } + }, + }) + ); + } catch (err) { + logger.error('Failed to initialize IndexedDB:', err); + } + }; + void initializeDatabase(); + }, []); + + if (!db) { + return null; + } + + return <>{children({ taskClient: new IndexedDBClient(db) })}; +}; + +export default IndexedDBDataProvider; diff --git a/src/data/providers/LocalStorageDataProvider.tsx b/src/data/providers/LocalStorageDataProvider.tsx new file mode 100644 index 0000000..1ff7f24 --- /dev/null +++ b/src/data/providers/LocalStorageDataProvider.tsx @@ -0,0 +1,9 @@ +import { DataProviderProps } from '@/data/types'; +import React from 'react'; +import LocalStorageTaskClient from '@/taskClient/LocalStorageTaskClient'; + +const LocalStorageDataProvider: React.FC = ({ children }) => { + return <>{children({ taskClient: new LocalStorageTaskClient() })}; +}; + +export default LocalStorageDataProvider; diff --git a/src/data/providers/SQLiteDataProvider.tsx b/src/data/providers/SQLiteDataProvider.tsx new file mode 100644 index 0000000..8fcbe22 --- /dev/null +++ b/src/data/providers/SQLiteDataProvider.tsx @@ -0,0 +1,32 @@ +import DbMigrationRunner from '@/DbMigrationRunner'; +import migrations from '../../../migrations'; +import logger from '@/logger'; +import React from 'react'; +import { DataProviderProps } from '@/data/types'; +import SQLiteTaskClient from '@/taskClient/SQLiteTaskClient'; +import SQLiteProvider from '@/SQLiteProvider'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; + +const SQLiteDataProvider: React.FC = ({ children }) => { + const [db, setDb] = React.useState(null); + + const migrateDbIfNeeded = async (db: SQLiteDatabase) => { + try { + await new DbMigrationRunner(db).apply(migrations); + logger.log('All migrations applied.'); + setDb(db); + } catch (err) { + logger.error(err); + } + }; + + return ( + + {!!db && children({ taskClient: new SQLiteTaskClient(db) })} + + ); +}; + +export default SQLiteDataProvider; diff --git a/src/data/sqliteDatabase.ts b/src/data/sqliteDatabase.ts new file mode 100644 index 0000000..7697ed8 --- /dev/null +++ b/src/data/sqliteDatabase.ts @@ -0,0 +1 @@ +export { openDatabaseAsync, SQLiteDatabase } from 'expo-sqlite'; diff --git a/src/data/sqliteDatabase.web.ts b/src/data/sqliteDatabase.web.ts new file mode 100644 index 0000000..a2152a6 --- /dev/null +++ b/src/data/sqliteDatabase.web.ts @@ -0,0 +1,57 @@ +import { BindParams } from 'sql.js'; +import initSqlJs from 'sql.js'; +export type SQLiteDatabase = any; +const sqlPromise = initSqlJs({ + locateFile: (file) => `https://sql.js.org/dist/${file}`, +}); + +export async function openDatabaseAsync(databaseName: string, options?: any): Promise { + const SQL = await sqlPromise; + + const db = new SQL.Database(); + return { + withTransactionAsync: async (task: () => Promise) => { + try { + db.exec('BEGIN'); + await task(); + db.exec('COMMIT'); + } catch (e) { + console.error('rollback', e); + db.exec('ROLLBACK'); + throw e; + } + }, + execAsync: async (source: string): Promise => { + db.exec(source); + }, + runAsync: async (source: string, params: BindParams): Promise => { + db.run(source, params); + return { + lastInsertRowId: db.exec('SELECT last_insert_rowid()')[0].values[0][0] as number, + changes: db.getRowsModified(), + }; + }, + getAllAsync: async (source: string, params: BindParams): Promise => { + const stmt = db.prepare(source); + stmt.bind(params); + const results = []; + while (stmt.step()) { + results.push(stmt.getAsObject()); + } + stmt.free(); + return results; + }, + getFirstAsync: async (source: string, params: any): Promise => { + const stmt = db.prepare(source); + stmt.bind(params); + stmt.step(); + const result = stmt.getAsObject(); + stmt.free(); + + return result as T; + }, + closeAsync: async () => { + db.close(); + }, + }; +} diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..323c071 --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,18 @@ +import { TaskClient } from '@/taskClient/types'; + +export enum PersistenceType { + sqlite = 'sqlite', + indexedDB = 'indexedDB', + localstorage = 'localstorage', +} + +export type DataProviderProps = { + children: (props: { taskClient: TaskClient }) => React.ReactNode; +}; + +export type IndexedDBSchema = { + tasks: { + key: number; + value: { id: number; task: string; createdAt: string; updatedAt: string }; + }; +}; diff --git a/src/taskClient/IndexedDBClient.ts b/src/taskClient/IndexedDBClient.ts new file mode 100644 index 0000000..25e9e72 --- /dev/null +++ b/src/taskClient/IndexedDBClient.ts @@ -0,0 +1,43 @@ +import { TaskClient } from '@/taskClient/types'; +import { Task } from '@/types'; +import { IDBPDatabase } from 'idb'; +import { IndexedDBSchema } from '@/data/types'; + +const toTask = (dbObject: IndexedDBSchema['tasks']['value']): Task => ({ + ...dbObject, + createdAt: new Date(dbObject.createdAt), + updatedAt: new Date(dbObject.updatedAt), +}); + +class IndexedDBClient implements TaskClient { + constructor(private db: IDBPDatabase) {} + + async add(task: string): Promise { + const now = new Date().toISOString(); + await this.db.add('tasks', { + id: Date.now(), + task, + createdAt: now, + updatedAt: now, + }); + } + + async delete(id: number): Promise { + await this.db.delete('tasks', id); + } + + async task(id: number): Promise { + const task = await this.db.get('tasks', id); + if (!task) { + return null; + } + return toTask(task); + } + + async tasks(): Promise { + const tasks = await this.db.getAll('tasks'); + return tasks.map(toTask); + } +} + +export default IndexedDBClient; diff --git a/src/taskClient/LocalStorageTaskClient.ts b/src/taskClient/LocalStorageTaskClient.ts new file mode 100644 index 0000000..ce20351 --- /dev/null +++ b/src/taskClient/LocalStorageTaskClient.ts @@ -0,0 +1,45 @@ +import { TaskClient } from '@/taskClient/types'; +import { Task } from '@/types'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +class LocalStorageTaskClient implements TaskClient { + private static key = 'tasks'; + + async tasks(): Promise { + const rawValues = await AsyncStorage.getItem(LocalStorageTaskClient.key); + if (!rawValues) { + return []; + } + const tasks: Task[] = JSON.parse(rawValues); + return tasks.map((task) => ({ + ...task, + createdAt: new Date(task.createdAt), + updatedAt: new Date(task.updatedAt), + })); + } + + async task(id: number): Promise { + const tasks = await this.tasks(); + return tasks.find((task) => task.id === id) || null; + } + + async add(task: string): Promise { + const allTasks = await this.tasks(); + const newTask: Task = { + id: allTasks.length + 1, + task, + createdAt: new Date(), + updatedAt: new Date(), + }; + const updatedTasks = [...allTasks, newTask]; + await AsyncStorage.setItem(LocalStorageTaskClient.key, JSON.stringify(updatedTasks)); + } + + async delete(id: number): Promise { + let tasks = await this.tasks(); + tasks = tasks.filter((task) => task.id !== id); + await AsyncStorage.setItem(LocalStorageTaskClient.key, JSON.stringify(tasks)); + } +} + +export default LocalStorageTaskClient; diff --git a/src/TaskClient.spec.ts b/src/taskClient/SQLiteTaskClient.spec.ts similarity index 75% rename from src/TaskClient.spec.ts rename to src/taskClient/SQLiteTaskClient.spec.ts index 329e3b8..3e89128 100644 --- a/src/TaskClient.spec.ts +++ b/src/taskClient/SQLiteTaskClient.spec.ts @@ -1,7 +1,7 @@ -import { openDatabaseAsync, SQLiteDatabase } from 'expo-sqlite'; -import TaskClient from '@/TaskClient'; +import SQLiteTaskClient from '@/taskClient/SQLiteTaskClient'; import DbMigrationRunner from '@/DbMigrationRunner'; -import migration1 from '../migrations/001_initial'; +import migration1 from '../../migrations/001_initial'; +import { SQLiteDatabase, openDatabaseAsync } from '@/data/sqliteDatabase'; describe('TaskClient', () => { let sqlite: SQLiteDatabase; @@ -12,7 +12,7 @@ describe('TaskClient', () => { }); it('should read all tasks', async () => { - const taskClient = new TaskClient(sqlite); + const taskClient = new SQLiteTaskClient(sqlite); const insertStmt = await sqlite.prepareAsync('INSERT INTO task(task) VALUES (?)'); await insertStmt.executeAsync(['Write']); diff --git a/src/TaskClient.ts b/src/taskClient/SQLiteTaskClient.ts similarity index 83% rename from src/TaskClient.ts rename to src/taskClient/SQLiteTaskClient.ts index cd75496..c865d85 100644 --- a/src/TaskClient.ts +++ b/src/taskClient/SQLiteTaskClient.ts @@ -1,7 +1,8 @@ -import { SQLiteDatabase } from 'expo-sqlite'; import { Task } from '@/types'; +import { TaskClient } from '@/taskClient/types'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; -class TaskClient { +class SQLiteTaskClient implements TaskClient { constructor(private db: SQLiteDatabase) {} async tasks(): Promise { @@ -19,7 +20,7 @@ class TaskClient { })); } - async task(id: string): Promise { + async task(id: number): Promise { const rawValue = await this.db.getFirstAsync< Omit & { createdAt: string; @@ -46,4 +47,4 @@ class TaskClient { } } -export default TaskClient; +export default SQLiteTaskClient; diff --git a/src/taskClient/types.ts b/src/taskClient/types.ts new file mode 100644 index 0000000..357219a --- /dev/null +++ b/src/taskClient/types.ts @@ -0,0 +1,8 @@ +import { Task } from '@/types'; + +export interface TaskClient { + tasks(): Promise; + task(id: number): Promise; + add(task: string): Promise; + delete(id: number): Promise; +} diff --git a/src/types.ts b/src/types.ts index 1a978b4..d9c0d2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { SQLiteDatabase } from 'expo-sqlite'; +import { SQLiteDatabase } from '@/data/sqliteDatabase'; export type UserVersion = { user_version: number;