Skip to content

Commit 4fc4974

Browse files
use authorization on mutations. Decode SQLite types to Convex mutation payloads. Update Schema with PowerSync CLI.
1 parent 1019af6 commit 4fc4974

22 files changed

Lines changed: 706 additions & 155 deletions

demos/react-convex-todolist/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,7 @@ Convex Auth handles user authentication (email/password). The Convex Auth sessio
5656
## Mutations
5757

5858
Client writes go into the PowerSync upload queue, which calls `uploadData()` in the connector. This calls Convex mutations directly via the `ConvexReactClient` (e.g. `lists:create`, `todos:update`, `todos:remove`).
59+
60+
## ID Mapping
61+
62+
Convex requires server-side generated row IDs, while PowerSync needs stable local IDs for queued writes before the backend mutation has completed. This demo uses PowerSync's [sequential ID mapping](https://docs.powersync.com/client-sdks/advanced/sequential-id-mapping#sequential-id-mapping) pattern: client-side rows use a local `uuid`, and Convex mutations resolve that `uuid` to the corresponding Convex `_id` on the server.

demos/react-convex-todolist/convex/_generated/api.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
*/
1010

1111
import type * as auth from "../auth.js";
12+
import type * as authorization from "../authorization.js";
1213
import type * as http from "../http.js";
1314
import type * as lists from "../lists.js";
15+
import type * as mutationErrors from "../mutationErrors.js";
1416
import type * as powersync_checkpoints from "../powersync_checkpoints.js";
1517
import type * as todos from "../todos.js";
1618

@@ -22,8 +24,10 @@ import type {
2224

2325
declare const fullApi: ApiFromModules<{
2426
auth: typeof auth;
27+
authorization: typeof authorization;
2528
http: typeof http;
2629
lists: typeof lists;
30+
mutationErrors: typeof mutationErrors;
2731
powersync_checkpoints: typeof powersync_checkpoints;
2832
todos: typeof todos;
2933
}>;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { AuthConfig } from 'convex/server';
2+
3+
const convexSiteUrl: string = process.env.CONVEX_SITE_URL ?? 'http://localhost:3211';
4+
5+
export default {
6+
providers: [
7+
{
8+
domain: convexSiteUrl,
9+
applicationID: 'convex'
10+
}
11+
]
12+
} satisfies AuthConfig;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getAuthUserId } from '@convex-dev/auth/server';
2+
import { ConvexError } from 'convex/values';
3+
import type { Doc } from './_generated/dataModel';
4+
import type { MutationCtx } from './_generated/server';
5+
import { MUTATION_ERROR_CODES, type MutationErrorCode } from './mutationErrors';
6+
7+
export function mutationError(code: MutationErrorCode, message: string) {
8+
return new ConvexError({
9+
code,
10+
message
11+
});
12+
}
13+
14+
export async function requireOwnerId(ctx: Pick<MutationCtx, 'auth'>) {
15+
const userId = await getAuthUserId(ctx);
16+
if (userId === null) {
17+
throw mutationError(MUTATION_ERROR_CODES.NOT_AUTHENTICATED, 'Not authenticated');
18+
}
19+
20+
return userId;
21+
}
22+
23+
export function assertOwnerIdMatches(actualOwnerId: string, expectedOwnerId: string) {
24+
if (actualOwnerId !== expectedOwnerId) {
25+
throw mutationError(MUTATION_ERROR_CODES.NOT_AUTHORIZED, 'Not authorized');
26+
}
27+
}
28+
29+
export function assertListOwner(list: Doc<'lists'>, ownerId: string) {
30+
assertOwnerIdMatches(list.owner_id, ownerId);
31+
}

demos/react-convex-todolist/convex/lists.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { v } from 'convex/values';
22
import { DatabaseReader, mutation } from './_generated/server';
3+
import { assertListOwner, assertOwnerIdMatches, mutationError, requireOwnerId } from './authorization';
4+
import { MUTATION_ERROR_CODES } from './mutationErrors';
35
import schema from './schema';
46

57
/**
@@ -22,6 +24,8 @@ export const findListByUuid = async (params: { db: DatabaseReader; uuid: string
2224
export const create = mutation({
2325
args: schema.tables.lists.validator,
2426
handler: async (ctx, args) => {
27+
const ownerId = await requireOwnerId(ctx);
28+
assertOwnerIdMatches(args.owner_id, ownerId);
2529
await ctx.db.insert('lists', args);
2630
}
2731
});
@@ -32,10 +36,16 @@ export const create = mutation({
3236
export const update = mutation({
3337
// The uuid is required, every other field is an optional patch
3438
args: v.object({ uuid: v.string() }).extend(schema.tables.lists.validator.partial().fields),
35-
handler: async ({ db }, { uuid, ...fields }) => {
39+
handler: async (ctx, { uuid, ...fields }) => {
40+
const { db } = ctx;
41+
const ownerId = await requireOwnerId(ctx);
3642
const matching = await findListByUuid({ db, uuid });
3743
if (!matching) {
38-
return;
44+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for uuid=${uuid}`);
45+
}
46+
assertListOwner(matching, ownerId);
47+
if (fields.owner_id !== undefined) {
48+
assertOwnerIdMatches(fields.owner_id, ownerId);
3949
}
4050
await db.patch(matching._id, fields);
4151
}
@@ -48,11 +58,14 @@ export const remove = mutation({
4858
args: {
4959
uuid: v.string()
5060
},
51-
handler: async ({ db }, { uuid }) => {
61+
handler: async (ctx, { uuid }) => {
62+
const { db } = ctx;
63+
const ownerId = await requireOwnerId(ctx);
5264
const matching = await findListByUuid({ db, uuid });
5365
if (!matching) {
54-
return;
66+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for uuid=${uuid}`);
5567
}
68+
assertListOwner(matching, ownerId);
5669
await db.delete(matching._id);
5770
}
5871
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const MUTATION_ERROR_CODES = {
2+
// We should never reach this on the client side.
3+
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
4+
NOT_AUTHORIZED: 'NOT_AUTHORIZED',
5+
NOT_FOUND: 'NOT_FOUND'
6+
} as const;
7+
8+
export type MutationErrorCode = (typeof MUTATION_ERROR_CODES)[keyof typeof MUTATION_ERROR_CODES];
9+
10+
export type ConvexMutationErrorData = {
11+
code?: MutationErrorCode;
12+
message?: string;
13+
};
14+
15+
export const UPLOAD_REJECTION_MUTATION_ERROR_CODES = new Set<string>([
16+
MUTATION_ERROR_CODES.NOT_AUTHORIZED,
17+
MUTATION_ERROR_CODES.NOT_FOUND
18+
]);

demos/react-convex-todolist/convex/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default defineSchema({
2929
*/
3030
uuid: v.string(),
3131
created_at: v.string(),
32-
completed_at: v.optional(v.union(v.null(), v.string())),
32+
completed_at: v.optional(v.string()),
3333
description: v.string(),
3434
completed: v.optional(v.number()),
3535
list_id: v.id('lists'),

demos/react-convex-todolist/convex/todos.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { v } from 'convex/values';
22
import { DatabaseReader, mutation } from './_generated/server';
3+
import { assertListOwner, mutationError, requireOwnerId } from './authorization';
34
import { findListByUuid } from './lists';
5+
import { MUTATION_ERROR_CODES } from './mutationErrors';
46
import schema from './schema';
57

68
/**
@@ -26,12 +28,14 @@ export const create = mutation({
2628
args: schema.tables.todos.validator.omit('list_id'),
2729
handler: async (ctx, args) => {
2830
const { db } = ctx;
31+
const ownerId = await requireOwnerId(ctx);
2932
const { list_uuid } = args;
3033
//need to set the corresponding list_id for the provided list_uuid
3134
const matchingList = await findListByUuid({ db, uuid: list_uuid });
3235
if (!matchingList) {
33-
throw new Error(`No matching list found for uuid=${list_uuid}`);
36+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for uuid=${list_uuid}`);
3437
}
38+
assertListOwner(matchingList, ownerId);
3539
return await db.insert('todos', {
3640
...args,
3741
list_id: matchingList._id
@@ -45,11 +49,28 @@ export const create = mutation({
4549
export const update = mutation({
4650
// The uuid is required, every other field is an optional patch
4751
args: v.object({ uuid: v.string() }).extend(schema.tables.todos.validator.partial().fields),
48-
handler: async ({ db }, { uuid, ...fields }) => {
52+
handler: async (ctx, { uuid, ...fields }) => {
53+
const { db } = ctx;
54+
const ownerId = await requireOwnerId(ctx);
4955
let matching = await findTodoByUuid({ db, uuid });
5056
if (!matching) {
51-
return;
57+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching todo found for uuid=${uuid}`);
58+
}
59+
const currentList = await db.get(matching.list_id);
60+
if (!currentList) {
61+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for todo uuid=${uuid}`);
62+
}
63+
assertListOwner(currentList, ownerId);
64+
65+
if (fields.list_uuid !== undefined) {
66+
const nextList = await findListByUuid({ db, uuid: fields.list_uuid });
67+
if (!nextList) {
68+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for uuid=${fields.list_uuid}`);
69+
}
70+
assertListOwner(nextList, ownerId);
71+
fields.list_id = nextList._id;
5272
}
73+
5374
await db.patch(matching._id, fields);
5475
}
5576
});
@@ -61,11 +82,18 @@ export const remove = mutation({
6182
args: {
6283
uuid: v.string()
6384
},
64-
handler: async ({ db }, { uuid }) => {
85+
handler: async (ctx, { uuid }) => {
86+
const { db } = ctx;
87+
const ownerId = await requireOwnerId(ctx);
6588
let matching = await findTodoByUuid({ db, uuid });
6689
if (!matching) {
67-
return;
90+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching todo found for uuid=${uuid}`);
91+
}
92+
const list = await db.get(matching.list_id);
93+
if (!list) {
94+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for todo uuid=${uuid}`);
6895
}
96+
assertListOwner(list, ownerId);
6997
await db.delete(matching._id);
7098
}
7199
});
@@ -75,8 +103,14 @@ export const createBatch = mutation({
75103
todos: v.array(schema.tables.todos.validator)
76104
},
77105
handler: async (ctx, args) => {
106+
const ownerId = await requireOwnerId(ctx);
78107
const ids = [];
79108
for (const todo of args.todos) {
109+
const list = await ctx.db.get(todo.list_id);
110+
if (!list) {
111+
throw mutationError(MUTATION_ERROR_CODES.NOT_FOUND, `No matching list found for id=${todo.list_id}`);
112+
}
113+
assertListOwner(list, ownerId);
80114
const id = await ctx.db.insert('todos', todo);
81115
ids.push(id);
82116
}

demos/react-convex-todolist/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"dev:convex": "convex dev",
99
"build": "tsc -b && vite build",
1010
"preview": "vite preview",
11-
"start": "pnpm build && pnpm preview"
11+
"start": "pnpm build && pnpm preview",
12+
"update:schema": "powersync generate schema --output-path=./src/library/powersync/AppSchema.ts --output=ts"
1213
},
1314
"dependencies": {
1415
"@convex-dev/auth": "^0.0.91",

demos/react-convex-todolist/powersync/sync-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ streams:
1919
FROM lists
2020
WHERE uuid in user_lists
2121
- |
22-
SELECT uuid as id, list_uuid as list_id, *
22+
SELECT uuid as id, *
2323
FROM todos
2424
WHERE list_uuid IN user_lists
2525
archived_user_data:
@@ -35,6 +35,6 @@ streams:
3535
FROM lists
3636
WHERE uuid in archived_user_lists
3737
- |
38-
SELECT uuid as id, list_uuid as list_id, *
38+
SELECT uuid as id, *
3939
FROM todos
4040
WHERE list_uuid IN archived_user_lists

0 commit comments

Comments
 (0)