|
| 1 | +--- |
| 2 | +title: Auto-Incrementing ID Mapping |
| 3 | +description: In this tutorial we will show you how to map the local uuid to a remote auto increment id. |
| 4 | +sidebarTitle: Auto-Incrementing ID Mapping |
| 5 | +keywords: ["data", "uuid", "map", "auto increment", "id", "sequential id"] |
| 6 | +--- |
| 7 | + |
| 8 | +# Introduction |
| 9 | +When auto-incrementing / sequential IDs are used on the backend database, the ID can only be generated on the backend database, and not on the client while offline. |
| 10 | +To handle this, you can use a secondary UUID on the client, then map them to a sequential ID when performing an update on the backend database. |
| 11 | +This allows using a sequential primary key for each record, with a UUID as a secondary ID. |
| 12 | + |
| 13 | +<Note> |
| 14 | + This mapping must be performed wherever the UUIDs are referenced, including for every foreign key column. |
| 15 | +</Note> |
| 16 | + |
| 17 | +To illustrate this, we will use the [To-Do List demo app](https://github.com/powersync-ja/powersync-js/tree/main/demos/react-supabase-todolist) and modify it to use UUIDs |
| 18 | +on the client and map them to sequential IDs on the backend database (Supabase in this case). |
| 19 | + |
| 20 | +### Overview |
| 21 | +Before we get started, let's outline the changes we will have to make: |
| 22 | +<Steps> |
| 23 | + <Step title={"Data model"}> |
| 24 | + Update the `lists` and `todos` tables |
| 25 | + </Step> |
| 26 | + |
| 27 | + <Step title={"Create SQL triggers"}> |
| 28 | + Add two triggers that will map the UUID to the integer ID and vice versa. |
| 29 | + </Step> |
| 30 | + |
| 31 | + <Step title={"Update Sync Rules"}> |
| 32 | + Update the Sync Rules to use the new integer ID instead of the UUID column. |
| 33 | + </Step> |
| 34 | + |
| 35 | + <Step title={"Update client to use UUIDs."}> |
| 36 | + The following components/files will have to be updated: |
| 37 | + - *Files*: |
| 38 | + - `AppSchema.ts` |
| 39 | + - `fts_setup.ts` |
| 40 | + - `SupabaseConnector.ts` |
| 41 | + - *Components*: |
| 42 | + - `lists.tsx` |
| 43 | + - `page.tsx` |
| 44 | + - `SearchBarWidget.tsx` |
| 45 | + - `TodoListsWidget.tsx` |
| 46 | + </Step> |
| 47 | +</Steps> |
| 48 | + |
| 49 | +# Data Model |
| 50 | + |
| 51 | +In order to map the UUID to the integer ID, we need to update the |
| 52 | +- `lists` table by adding a `uuid` column, which will be the secondary ID, and |
| 53 | +- `todos` table by adding a `uuid` column, and a `list_uuid` foreign key column which references the `uuid` column in the `lists` table. |
| 54 | + |
| 55 | +<CodeGroup> |
| 56 | + ```sql data model {3, 13, 21, 26} |
| 57 | + create table public.lists ( |
| 58 | + id serial, |
| 59 | + uuid uuid not null unique, |
| 60 | + created_at timestamp with time zone not null default now(), |
| 61 | + name text not null, |
| 62 | + owner_id uuid not null, |
| 63 | + constraint lists_pkey primary key (id), |
| 64 | + constraint lists_owner_id_fkey foreign key (owner_id) references auth.users (id) on delete cascade |
| 65 | + ) tablespace pg_default; |
| 66 | + |
| 67 | + create table public.todos ( |
| 68 | + id serial, |
| 69 | + uuid uuid not null unique, |
| 70 | + created_at timestamp with time zone not null default now(), |
| 71 | + completed_at timestamp with time zone null, |
| 72 | + description text not null, |
| 73 | + completed boolean not null default false, |
| 74 | + created_by uuid null, |
| 75 | + completed_by uuid null, |
| 76 | + list_id int not null, |
| 77 | + list_uuid uuid not null, |
| 78 | + constraint todos_pkey primary key (id), |
| 79 | + constraint todos_created_by_fkey foreign key (created_by) references auth.users (id) on delete set null, |
| 80 | + constraint todos_completed_by_fkey foreign key (completed_by) references auth.users (id) on delete set null, |
| 81 | + constraint todos_list_id_fkey foreign key (list_id) references lists (id) on delete cascade, |
| 82 | + constraint todos_list_uuid_fkey foreign key (list_uuid) references lists (uuid) on delete cascade |
| 83 | + ) tablespace pg_default; |
| 84 | + ``` |
| 85 | +</CodeGroup> |
| 86 | + |
| 87 | +With the data mode updated, we now need a method to synchronize and map the `list_id` and `list_uuid` in the `todos` table, with the `id` and `uuid` columns in the `lists` table. |
| 88 | +We can achieve this by creating SQL triggers. |
| 89 | + |
| 90 | +# Create SQL Triggers |
| 91 | + |
| 92 | +We need to create triggers that can look up the integer ID for the given UUID and vice versa. |
| 93 | +These triggers will maintain consistency between `list_id` and `list_uuid` in the `todos` table by ensuring that they remain synchronized with the `id` and `uuid` columns in the `lists` table; |
| 94 | +even if changes are made to either field. |
| 95 | + |
| 96 | +We will create the following two triggers that cover either scenario of updating the `list_id` or `list_uuid` in the `todos` table: |
| 97 | +1. `update_integer_id`, and |
| 98 | +2. `update_uuid_column` |
| 99 | + |
| 100 | +<AccordionGroup> |
| 101 | + <Accordion title="Trigger 1: update_integer_id" defaultOpen={true}> |
| 102 | + The `update_integer_id` trigger ensures that whenever a `list_uuid` value is inserted or updated in the `todos` table, |
| 103 | + the corresponding `list_id` is fetched from the `lists` table and updated automatically. It also validates that the `list_uuid` exists in the `lists` table; otherwise, it raises an exception. |
| 104 | + |
| 105 | + ```sql |
| 106 | + CREATE OR REPLACE FUNCTION func_update_integer_id() |
| 107 | + RETURNS TRIGGER AS $$ |
| 108 | + BEGIN |
| 109 | + IF TG_OP = 'INSERT' THEN |
| 110 | + -- Always update list_id on INSERT |
| 111 | + SELECT id INTO NEW.list_id |
| 112 | + FROM lists |
| 113 | + WHERE uuid = NEW.list_uuid; |
| 114 | + |
| 115 | + IF NOT FOUND THEN |
| 116 | + RAISE EXCEPTION 'UUID % does not exist in lists', NEW.list_uuid; |
| 117 | + END IF; |
| 118 | + |
| 119 | + ELSIF TG_OP = 'UPDATE' THEN |
| 120 | + -- Only update list_id if list_uuid changes |
| 121 | + IF NEW.list_uuid IS DISTINCT FROM OLD.list_uuid THEN |
| 122 | + SELECT id INTO NEW.list_id |
| 123 | + FROM lists |
| 124 | + WHERE uuid = NEW.list_uuid; |
| 125 | + |
| 126 | + IF NOT FOUND THEN |
| 127 | + RAISE EXCEPTION 'UUID % does not exist in lists', NEW.list_uuid; |
| 128 | + END IF; |
| 129 | + END IF; |
| 130 | + END IF; |
| 131 | + |
| 132 | + RETURN NEW; |
| 133 | + END; |
| 134 | + $$ LANGUAGE plpgsql; |
| 135 | + |
| 136 | + CREATE TRIGGER update_integer_id |
| 137 | + BEFORE INSERT OR UPDATE ON todos |
| 138 | + FOR EACH ROW |
| 139 | + EXECUTE FUNCTION func_update_integer_id(); |
| 140 | + ``` |
| 141 | + </Accordion> |
| 142 | + <Accordion title="Trigger 2: update_uuid_column" defaultOpen={true}> |
| 143 | + The `update_uuid_column` trigger ensures that whenever a `list_id` value is inserted or updated in the todos table, the corresponding `list_uuid` is fetched from the |
| 144 | + `lists` table and updated automatically. It also validates that the `list_id` exists in the `lists` table. |
| 145 | + |
| 146 | + ```sql update_uuid_column |
| 147 | + CREATE OR REPLACE FUNCTION func_update_uuid_column() |
| 148 | + RETURNS TRIGGER AS $$ |
| 149 | + BEGIN |
| 150 | + IF TG_OP = 'INSERT' THEN |
| 151 | + -- Always update list_uuid on INSERT |
| 152 | + SELECT uuid INTO NEW.list_uuid |
| 153 | + FROM lists |
| 154 | + WHERE id = NEW.list_id; |
| 155 | + |
| 156 | + IF NOT FOUND THEN |
| 157 | + RAISE EXCEPTION 'ID % does not exist in lists', NEW.list_id; |
| 158 | + END IF; |
| 159 | + |
| 160 | + ELSIF TG_OP = 'UPDATE' THEN |
| 161 | + -- Only update list_uuid if list_id changes |
| 162 | + IF NEW.list_id IS DISTINCT FROM OLD.list_id THEN |
| 163 | + SELECT uuid INTO NEW.list_uuid |
| 164 | + FROM lists |
| 165 | + WHERE id = NEW.list_id; |
| 166 | + |
| 167 | + IF NOT FOUND THEN |
| 168 | + RAISE EXCEPTION 'ID % does not exist in lists', NEW.list_id; |
| 169 | + END IF; |
| 170 | + END IF; |
| 171 | + END IF; |
| 172 | + |
| 173 | + RETURN NEW; |
| 174 | + END; |
| 175 | + $$ LANGUAGE plpgsql; |
| 176 | + |
| 177 | + CREATE TRIGGER update_uuid_column |
| 178 | + BEFORE INSERT OR UPDATE ON todos |
| 179 | + FOR EACH ROW |
| 180 | + EXECUTE FUNCTION func_update_uuid_column(); |
| 181 | + ``` |
| 182 | + </Accordion> |
| 183 | +</AccordionGroup> |
| 184 | + |
| 185 | +We now have triggers in place that will handle the mapping for our updated data model and |
| 186 | +can move on to updating the Sync Rules to use the UUID column instead of the integer ID. |
| 187 | + |
| 188 | +# Update Sync Rules |
| 189 | + |
| 190 | +As sequential IDs can only be created on the backend database, we need to use UUIDs in the client. This can be done by updating both the `parameters` and `data` queries to use the new `uuid` columns. |
| 191 | +The `parameters` query is updated by removing the `list_id` alias (this is removed to avoid any confusion between the `list_id` column in the `todos` table), and |
| 192 | +the `data` query is updated to use the `uuid` column as the `id` column for the `lists` and `todos` tables. We also explicitly define which columns to select, as `list_id` is no longer required in the client. |
| 193 | + |
| 194 | +```yaml sync_rules.yaml {4, 7-8} |
| 195 | +bucket_definitions: |
| 196 | + user_lists: |
| 197 | + # Separate bucket per todo list |
| 198 | + parameters: select id from lists where owner_id = request.user_id() |
| 199 | + data: |
| 200 | + # Explicitly define all the columns |
| 201 | + - select uuid as id, created_at, name, owner_id from lists where id = bucket.id |
| 202 | + - select uuid as id, created_at, completed_at, description, completed, created_by, list_uuid from todos where list_id = bucket.id |
| 203 | +``` |
| 204 | +
|
| 205 | +With the Sync Rules updated, we can now move on to updating the client to use UUIDs. |
| 206 | +
|
| 207 | +# Update Client to Use UUIDs |
| 208 | +
|
| 209 | +With our Sync Rules updated, we no longer have the `list_id` column in the `todos` table. |
| 210 | +We start by updating `AppSchema.ts` and replacing `list_id` with `list_uuid` in the `todos` table. |
| 211 | +```typescript AppSchema.ts {3, 11} |
| 212 | +const todos = new Table( |
| 213 | + { |
| 214 | + list_uuid: column.text, |
| 215 | + created_at: column.text, |
| 216 | + completed_at: column.text, |
| 217 | + description: column.text, |
| 218 | + created_by: column.text, |
| 219 | + completed_by: column.text, |
| 220 | + completed: column.integer |
| 221 | + }, |
| 222 | + { indexes: { list: ['list_uuid'] } } |
| 223 | +); |
| 224 | +``` |
| 225 | + |
| 226 | +The `uploadData` function in `SupabaseConnector.ts` needs to be updated to use the new `uuid` column in both tables. |
| 227 | + |
| 228 | +```typescript SupabaseConnector.ts {13, 17, 20} |
| 229 | +export class SupabaseConnector extends BaseObserver<SupabaseConnectorListener> implements PowerSyncBackendConnector { |
| 230 | + // other code |
| 231 | +
|
| 232 | + async uploadData(database: AbstractPowerSyncDatabase): Promise<void> { |
| 233 | + // other code |
| 234 | + try { |
| 235 | + for (const op of transaction.crud) { |
| 236 | + lastOp = op; |
| 237 | + const table = this.client.from(op.table); |
| 238 | + let result: any; |
| 239 | + switch (op.op) { |
| 240 | + case UpdateType.PUT: |
| 241 | + const record = { ...op.opData, uuid: op.id }; |
| 242 | + result = await table.upsert(record); |
| 243 | + break; |
| 244 | + case UpdateType.PATCH: |
| 245 | + result = await table.update(op.opData).eq('uuid', op.id); |
| 246 | + break; |
| 247 | + case UpdateType.DELETE: |
| 248 | + result = await table.delete().eq('uuid', op.id); |
| 249 | + break; |
| 250 | + } |
| 251 | + } |
| 252 | + } catch (ex: any) { |
| 253 | + // other code |
| 254 | + } |
| 255 | + } |
| 256 | +} |
| 257 | +``` |
| 258 | + |
| 259 | +<Note> |
| 260 | + For the remaining files, we simply need to replace any reference to `list_id` with `list_uuid`. |
| 261 | +</Note> |
| 262 | + |
| 263 | +```typescript fts_setup.ts {3} |
| 264 | +export async function configureFts(): Promise<void> { |
| 265 | + await createFtsTable('lists', ['name'], 'porter unicode61'); |
| 266 | + await createFtsTable('todos', ['description', 'list_uuid']); |
| 267 | +} |
| 268 | +``` |
| 269 | + |
| 270 | +```tsx page.tsx {4, 14} |
| 271 | +const TodoEditSection = () => { |
| 272 | + // code |
| 273 | + const { data: todos } = useQuery<TodoRecord>( |
| 274 | + `SELECT * FROM ${TODOS_TABLE} WHERE list_uuid=? ORDER BY created_at DESC, id`, |
| 275 | + [listID] |
| 276 | + ); |
| 277 | + |
| 278 | + // code |
| 279 | + const createNewTodo = async (description: string) => { |
| 280 | + // other code |
| 281 | + await powerSync.execute( |
| 282 | + `INSERT INTO |
| 283 | + ${TODOS_TABLE} |
| 284 | + (id, created_at, created_by, description, list_uuid) |
| 285 | + VALUES |
| 286 | + (uuid(), datetime(), ?, ?, ?)`, |
| 287 | + [userID, description, listID!] |
| 288 | + ); |
| 289 | + } |
| 290 | +} |
| 291 | +``` |
| 292 | + |
| 293 | +```tsx TodoListWidget.tsx {10, 18} |
| 294 | +export function TodoListsWidget(props: TodoListsWidgetProps) { |
| 295 | + // hooks and navigation |
| 296 | + |
| 297 | + const { data: listRecords, isLoading } = useQuery<ListRecord & { total_tasks: number; completed_tasks: number }>(` |
| 298 | + SELECT |
| 299 | + ${LISTS_TABLE}.*, COUNT(${TODOS_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODOS_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks |
| 300 | + FROM |
| 301 | + ${LISTS_TABLE} |
| 302 | + LEFT JOIN ${TODOS_TABLE} |
| 303 | + ON ${LISTS_TABLE}.id = ${TODOS_TABLE}.list_uuid |
| 304 | + GROUP BY |
| 305 | + ${LISTS_TABLE}.id; |
| 306 | + `); |
| 307 | + |
| 308 | + const deleteList = async (id: string) => { |
| 309 | + await powerSync.writeTransaction(async (tx) => { |
| 310 | + // Delete associated todos |
| 311 | + await tx.execute(`DELETE FROM ${TODOS_TABLE} WHERE list_uuid = ?`, [id]); |
| 312 | + // Delete list record |
| 313 | + await tx.execute(`DELETE FROM ${LISTS_TABLE} WHERE id = ?`, [id]); |
| 314 | + }); |
| 315 | + }; |
| 316 | +} |
| 317 | +``` |
| 318 | + |
| 319 | +```tsx SearchBarWidget.tsx {8, 19} |
| 320 | +export const SearchBarWidget: React.FC<any> = () => { |
| 321 | + const handleInputChange = async (value: string) => { |
| 322 | + if (value.length !== 0) { |
| 323 | + let listsSearchResults: any[] = []; |
| 324 | + const todoItemsSearchResults = await searchTable(value, 'todos'); |
| 325 | + for (let i = 0; i < todoItemsSearchResults.length; i++) { |
| 326 | + const res = await powersync.get<ListRecord>(`SELECT * FROM ${LISTS_TABLE} WHERE id = ?`, [ |
| 327 | + todoItemsSearchResults[i]['list_uuid'] |
| 328 | + ]); |
| 329 | + todoItemsSearchResults[i]['list_name'] = res.name; |
| 330 | + } |
| 331 | + if (!todoItemsSearchResults.length) { |
| 332 | + listsSearchResults = await searchTable(value, 'lists'); |
| 333 | + } |
| 334 | + const formattedListResults: SearchResult[] = listsSearchResults.map( |
| 335 | + (result) => new SearchResult(result['id'], result['name']) |
| 336 | + ); |
| 337 | + const formattedTodoItemsResults: SearchResult[] = todoItemsSearchResults.map((result) => { |
| 338 | + return new SearchResult(result['list_uuid'], result['list_name'] ?? '', result['description']); |
| 339 | + }); |
| 340 | + setSearchResults([...formattedTodoItemsResults, ...formattedListResults]); |
| 341 | + } |
| 342 | + }; |
| 343 | +} |
| 344 | +``` |
0 commit comments