diff --git a/sqlitesync-todoapp/README.md b/sqlitesync-todoapp/README.md new file mode 100644 index 0000000..4214d01 --- /dev/null +++ b/sqlitesync-todoapp/README.md @@ -0,0 +1,167 @@ +# SQLiteSync Demo: Multiuser To-Do App + +This repository demonstrates how to use **SQLiteSync**, our CRDT-based synchronization solution, to keep data synchronized between multiple **edge devices** and the **SQLiteCloud** service. It features: + +- Cloud-side configuration of synchronization and Row-Level Security (RLS) +- Edge device simulation using the `sqlite3` CLI +- A multitenant **To-Do application** data model + +## Overview + +**SQLiteSync** extends SQLite to enable automatic data synchronization between local databases on edge devices and a centralized **SQLiteCloud** instance. This example shows: + +- How to configure **SQLiteCloud** for **SQLiteSync** (CRDT-based synchronization) and RLS +- How to simulate edge clients using the SQLite CLI +- How RLS restricts data access based on user permissions +- How to create and interact with the schema for a collaborative to-do app + +## Requirements + +- [SQLiteCloud account](https://sqlitecloud.io) +- SQLite 3.45+ CLI with extension loading support +- [SQLiteSync](https://github.com/sqliteai/sqlite-sync) extension +- Access to this repository's `.sql` files + +## Schema + +The schema for this demo models a multitenant to-do list application with user access control. + +```sql +-- USERS +CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE DEFAULT "", + email TEXT NOT NULL UNIQUE DEFAULT "" +) WITHOUT ROWID; + +-- TODO LISTS +CREATE TABLE todo_lists ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT "", + description TEXT DEFAULT NULL, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) WITHOUT ROWID; + +-- PERMISSIONS +CREATE TABLE permissions ( + user_id TEXT NOT NULL, + list_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'editor', 'viewer')), + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, list_id), + FOREIGN KEY (list_id) REFERENCES todo_lists(id) +) WITHOUT ROWID; + +-- TODOS +CREATE TABLE todos ( + id TEXT PRIMARY KEY NOT NULL, + list_id TEXT, + title TEXT, + is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)), + due_date DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (list_id) REFERENCES todo_lists(id) +) WITHOUT ROWID; +``` + +## Setup Instructions + +### 1. Configure SQLiteCloud + +#### On the Dashboard + +1. Log in to your [SQLiteCloud Dashboard](https://dashboard.sqlitecloud.io). +2. Upload the database from `./db/todoapp.sqlite` +3. Enable **SQLiteSync** (OffSync button) for each table in the database from the project's Databases page. +4. Enable **Row-Level Security (RLS)** and configure policies for the `permissions`, `todo_lists`, `todos`, and `users` tables. You can copy each statement from the `./sql/cloud_rls_.sql` files. These rules ensure that users can only view and interact with: + - Lists they created + - Lists shared with them via the `permissions` table (as owner, editor, or viewer) + - Todos belonging to those lists +5. Insert the first users of the app. From the Studio page, execute the following lines from `./sql/cloud_insert_users.sql`: + +```sql +INSERT INTO users (id, username, email) VALUES + ('018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1', 'alice', 'alice@example.com'), + ('018ecfc2-b2b1-7cc3-a9f0-987cef6b49f2', 'bob', 'bob@example.com'), + ('018ecfc2-b2b2-7cc4-a9f0-987cef6b49f3', 'carol', 'carol@example.com'), + ('018ecfc2-b2b3-7cc5-a9f0-987cef6b49f4', 'dan', 'dan@example.com'); +``` + +6. Create a token for each user (`alice`, `bob`, `carol`, and `dan`) using a REST API call. For example, you can try the REST API with `curl` from a terminal and extract the `token` from the response. + + ``` + curl -X "POST" "https:///v2/tokens" \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json; charset=utf-8' \ + -d $'{ + "name": "alice", + "userId": "018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1" + }' + ``` + +### 2. Edge Devices Simulation + +Each edge device simulates a different user using the `sqlite3` CLI. Alternatively, you can execute the same SQL queries programmatically using your preferred programming language and SQLite driver. + +#### Example Edge Device Setup + +```bash +sqlite3 todoapp_edge_device_1.sqlite +``` + +Then, inside the CLI: + +```sql +-- Load the extension +.load /path/to/extension/cloudsync.dylib + +-- Load the schema +.read sql/schema.sql + +-- Init cloudsync for all the tables +SELECT cloudsync_init('*'); + +-- Load initial sync state if needed +.read sql/edge_device_1_init.sql + +-- Connect to the cloud +SELECT cloudsync_network_init('sqlitecloud:///todoapp.sqlite'); +SELECT cloudsync_set_token(''); + +-- Start the synchronization process: +-- 1. Retrieve changes by periodically invoking cloudsync_network_check_changes: +SELECT cloudsync_network_check_changes(); +-- ... +SELECT cloudsync_network_check_changes(); + +-- 2. Send local changes: +SELECT cloudsync_network_send_changes(); + +-- Run your application queries here +-- for example: +-- SELECT * FROM users; +-- SELECT * FROM todos JOIN todo_lists ON todos.list_id = todo_lists.id + +-- Before closing the db connection, close cloudsync +SELECT cloudsync_terminate() +``` + +Repeat similar steps for `edge_device_2.db` and `edge_device_3.db`, using their respective `.sql` files and tokens for users `bob` and `carol`. + +## Using the Demo + +After setup: + +- Any `INSERT`, `UPDATE`, or `DELETE` operation on a local database will be propagated to the cloud and synchronized across all clients using `SELECT cloudsync_network_send_changes();`, subject to RLS rules. +- Retrieve remote changes by periodically calling `SELECT cloudsync_network_check_changes();`. +- The app can query the local database for immediate responses, avoiding network latency. +- Changes made on one edge device will eventually propagate to others, in accordance with the CRDT model. + +## License + +This demo is provided under the MIT License. See [LICENSE](LICENSE) for more details. + +## Contact + +For support or more information, visit [sqlitecloud.io](https://sqlitecloud.io) or contact us at support@sqlitecloud.io. diff --git a/sqlitesync-todoapp/db/todoapp.sqlite b/sqlitesync-todoapp/db/todoapp.sqlite new file mode 100644 index 0000000..016dde2 Binary files /dev/null and b/sqlitesync-todoapp/db/todoapp.sqlite differ diff --git a/sqlitesync-todoapp/sql/cloud_insert_users.sql b/sqlitesync-todoapp/sql/cloud_insert_users.sql new file mode 100644 index 0000000..c8eecc9 --- /dev/null +++ b/sqlitesync-todoapp/sql/cloud_insert_users.sql @@ -0,0 +1,5 @@ +INSERT INTO users (id, username, email) VALUES + ('018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1', 'alice', 'alice@example.com'), + ('018ecfc2-b2b1-7cc3-a9f0-987cef6b49f2', 'bob', 'bob@example.com'), + ('018ecfc2-b2b2-7cc4-a9f0-987cef6b49f3', 'carol', 'carol@example.com'), + ('018ecfc2-b2b3-7cc5-a9f0-987cef6b49f4', 'dan', 'dan@example.com'); \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/cloud_rls_permissions.sql b/sqlitesync-todoapp/sql/cloud_rls_permissions.sql new file mode 100644 index 0000000..1559735 --- /dev/null +++ b/sqlitesync-todoapp/sql/cloud_rls_permissions.sql @@ -0,0 +1,45 @@ +-- TABLE: permissions +-- select: +list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() +) + +-- insert: +NEW.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) +OR +auth_userid() = ( + SELECT created_by + FROM todo_lists + WHERE id = NEW.list_id +) + +-- update: +NEW.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) +AND +OLD.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) + +-- delete: +OLD.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) +OR OLD.user_id = auth_userid() \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/cloud_rls_todo_lists.sql b/sqlitesync-todoapp/sql/cloud_rls_todo_lists.sql new file mode 100644 index 0000000..5a1c432 --- /dev/null +++ b/sqlitesync-todoapp/sql/cloud_rls_todo_lists.sql @@ -0,0 +1,30 @@ +-- TABLE: todo_lists +-- select: +todo_lists.id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor', 'viewer') +) +OR todo_lists.created_by = auth_userid() + +-- insert: +NEW.created_by = auth_userid() + +-- update: +OLD.id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) +OR OLD.created_by = auth_userid() + +-- delete: +OLD.id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner') +) +OR OLD.created_by = auth_userid() \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/cloud_rls_todos.sql b/sqlitesync-todoapp/sql/cloud_rls_todos.sql new file mode 100644 index 0000000..d27c87a --- /dev/null +++ b/sqlitesync-todoapp/sql/cloud_rls_todos.sql @@ -0,0 +1,59 @@ +-- TABLE: todos +-- select: +todos.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor', 'viewer') + UNION + SELECT id AS list_id + FROM todo_lists + WHERE created_by = auth_userid() +) + +-- insert: +NEW.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor') + UNION + SELECT id AS list_id + FROM todo_lists + WHERE created_by = auth_userid() +) + +-- update: +NEW.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor') + UNION + SELECT id AS list_id + FROM todo_lists + WHERE created_by = auth_userid() +) +AND +OLD.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor') + UNION + SELECT id AS list_id + FROM todo_lists + WHERE created_by = auth_userid() +) + +-- delete: +OLD.list_id IN ( + SELECT list_id + FROM permissions + WHERE user_id = auth_userid() + AND role IN ('owner', 'editor') + UNION + SELECT id AS list_id + FROM todo_lists + WHERE created_by = auth_userid() +) \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/cloud_rls_users.sql b/sqlitesync-todoapp/sql/cloud_rls_users.sql new file mode 100644 index 0000000..4dcdee6 --- /dev/null +++ b/sqlitesync-todoapp/sql/cloud_rls_users.sql @@ -0,0 +1,12 @@ +-- TABLE: users +-- select: +users.id = auth_userid() + +-- insert: +NEW.id = auth_userid() + +-- update: +NEW.id = auth_userid() AND OLD.id = auth_userid() + +-- delete: +OLD.id = auth_userid() \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/edge_device_1_init.sql b/sqlitesync-todoapp/sql/edge_device_1_init.sql new file mode 100644 index 0000000..56de9bc --- /dev/null +++ b/sqlitesync-todoapp/sql/edge_device_1_init.sql @@ -0,0 +1,16 @@ +INSERT INTO users (id, username, email) VALUES + ('018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1', 'alice', 'alice@example.com'); + +INSERT INTO todo_lists (id, title, description, created_by) VALUES + ('018ecfc3-c101-7cc5-a9f0-987cef6b49f4', 'Groceries', 'Weekly shopping list', '018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1'); + +INSERT INTO todos (id, list_id, title, is_done) VALUES + ('018ecfc4-d201-7cc7-a9f0-987cef6b49f6', '018ecfc3-c101-7cc5-a9f0-987cef6b49f4', 'Buy milk', 0), + ('018ecfc4-d202-7cc8-a9f0-987cef6b49f7', '018ecfc3-c101-7cc5-a9f0-987cef6b49f4', 'Buy eggs', 0); + +-- Alice owns Groceries list +INSERT INTO permissions (user_id, list_id, role) VALUES + ('018ecfc2-b2b0-7cc2-a9f0-987cef6b49f1', '018ecfc3-c101-7cc5-a9f0-987cef6b49f4', 'owner'); +-- Bob can view Groceries list +INSERT INTO permissions (user_id, list_id, role) VALUES + ('018ecfc2-b2b1-7cc3-a9f0-987cef6b49f2', '018ecfc3-c101-7cc5-a9f0-987cef6b49f4', 'viewer'); \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/edge_device_2_init.sql b/sqlitesync-todoapp/sql/edge_device_2_init.sql new file mode 100644 index 0000000..06f0901 --- /dev/null +++ b/sqlitesync-todoapp/sql/edge_device_2_init.sql @@ -0,0 +1,2 @@ +-- Download lists created on edge_device_1, based on the current user's token and permissions +SELECT cloudsync_network_check_changes(); \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/edge_device_3_init.sql b/sqlitesync-todoapp/sql/edge_device_3_init.sql new file mode 100644 index 0000000..286b296 --- /dev/null +++ b/sqlitesync-todoapp/sql/edge_device_3_init.sql @@ -0,0 +1,7 @@ +INSERT INTO todo_lists (id, title, description, created_by) VALUES + ('018ecfc3-c102-7cc6-a9f0-987cef6b49f5', 'Work Tasks', 'Project deadlines and notes', '018ecfc2-b2b2-7cc4-a9f0-987cef6b49f3'); +INSERT INTO permissions (user_id, list_id, role) VALUES + ('018ecfc2-b2b2-7cc4-a9f0-987cef6b49f3', '018ecfc3-c102-7cc6-a9f0-987cef6b49f5', 'owner'); +INSERT INTO todos (id, list_id, title, is_done) VALUES + ('018ecfc4-d203-7cc9-a9f0-987cef6b49f8', '018ecfc3-c102-7cc6-a9f0-987cef6b49f5', 'Finish Q1 report', 1), + ('018ecfc4-d204-7cca-a9f0-987cef6b49f9', '018ecfc3-c102-7cc6-a9f0-987cef6b49f5', 'Prepare slides for meeting', 0); \ No newline at end of file diff --git a/sqlitesync-todoapp/sql/schema.sql b/sqlitesync-todoapp/sql/schema.sql new file mode 100644 index 0000000..9f4bd6a --- /dev/null +++ b/sqlitesync-todoapp/sql/schema.sql @@ -0,0 +1,36 @@ +-- USERS +CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE DEFAULT "", + email TEXT NOT NULL UNIQUE DEFAULT "" +) WITHOUT ROWID; + +-- TODO LISTS +CREATE TABLE todo_lists ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT "", + description TEXT DEFAULT NULL, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) WITHOUT ROWID; + +-- PERMISSIONS +CREATE TABLE permissions ( + user_id TEXT NOT NULL, + list_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'editor', 'viewer')), + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, list_id), + FOREIGN KEY (list_id) REFERENCES todo_lists(id) +) WITHOUT ROWID; + +-- TODOS +CREATE TABLE todos ( + id TEXT PRIMARY KEY NOT NULL, + list_id TEXT, + title TEXT, + is_done INTEGER NOT NULL DEFAULT 0 CHECK (is_done IN (0, 1)), + due_date DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (list_id) REFERENCES todo_lists(id) +) WITHOUT ROWID; \ No newline at end of file