Skip to content

Commit 8376867

Browse files
committed
docs: use mutators and saved queries
1 parent 53bd8ab commit 8376867

File tree

10 files changed

+279
-71
lines changed

10 files changed

+279
-71
lines changed

.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ ZERO_UPSTREAM_DB="postgresql://user:[email protected]:5430/postgres"
33
ZERO_AUTH_SECRET="secretkey"
44
ZERO_REPLICA_FILE="/tmp/hello_zero_replica.db"
55
ZERO_LOG_LEVEL="debug"
6+
ZERO_GET_QUERIES_URL="http://localhost:5173/api/get-queries"
7+
ZERO_MUTATE_URL="http://localhost:5173/api/push"

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,19 +108,35 @@ createRoot(document.getElementById("root")!).render(
108108
);
109109
```
110110

111-
4. **Using Zero in Components** Example usage in React components. See
111+
4. **Set up saved queries** See [queries.ts](src/queries.ts):
112+
113+
```typescript
114+
import { savedQuery } from "@rocicorp/zero";
115+
import { schema } from "./schema";
116+
117+
const builder = createBuilder(schema);
118+
119+
export const queries = {
120+
users: savedQuery('users', z.tuple([]), () => {
121+
return builder.user.orderBy('name', 'asc');
122+
}
123+
};
124+
```
125+
126+
5. **Using Zero in Components** Example usage in React components. See
112127
[App.tsx](src/App.tsx):
113128
114129
```typescript
115130
import { useQuery, useZero } from "@rocicorp/zero/react";
116131
import { Schema } from "./schema";
132+
import { queries } from "./queries";
117133

118134
// You may want to put this in its own file
119135
const useZ = useZero<Schema>;
120136

121137
export function UsersPage() {
122138
const z = useZ();
123-
const users = useQuery(z.query.user);
139+
const [users] = useQuery(queries.users());
124140

125141
if (!users) {
126142
return null;
@@ -138,7 +154,8 @@ export function UsersPage() {
138154
```
139155
140156
For more examples of queries, mutations, and relationships, explore the
141-
[App.tsx](src/App.tsx) file in this repository.
157+
[queries.ts](src/queries.ts) and [mutators.ts](src/mutators.ts) files in this
158+
repository.
142159
143160
### Optional: Authentication
144161

api/index.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { Hono } from "hono";
22
import { setCookie } from "hono/cookie";
33
import { handle } from "hono/vercel";
4-
import { SignJWT } from "jose";
4+
import { SignJWT, jwtVerify } from "jose";
5+
import { Pool } from "pg";
6+
import { withValidation, ReadonlyJSONValue } from "@rocicorp/zero";
7+
import { PushProcessor } from "@rocicorp/zero/server";
8+
import { zeroNodePg } from "@rocicorp/zero/server/adapters/pg";
9+
import { handleGetQueriesRequest } from "@rocicorp/zero/server";
10+
import { AuthData, schema } from "../src/schema";
11+
import { createMutators } from "../src/mutators";
12+
import { queries, allUsers } from "../src/queries";
513

614
export const config = {
715
runtime: "edge",
816
};
917

18+
const pool = new Pool({
19+
connectionString: process.env.ZERO_UPSTREAM_DB,
20+
});
21+
22+
const pushProcessor = new PushProcessor(zeroNodePg(schema, pool));
23+
1024
export const app = new Hono().basePath("/api");
1125

1226
// See seed.sql
@@ -45,6 +59,71 @@ app.get("/login", async (c) => {
4559
return c.text("ok");
4660
});
4761

62+
async function getAuthData(request: Request): Promise<AuthData | undefined> {
63+
const authHeader = request.headers.get("Authorization");
64+
const token = authHeader?.replace("Bearer ", "");
65+
66+
if (!token) {
67+
return undefined;
68+
}
69+
70+
try {
71+
const { payload } = await jwtVerify(
72+
token,
73+
new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)),
74+
);
75+
return { sub: payload.sub as string | null };
76+
} catch {
77+
return undefined;
78+
}
79+
}
80+
81+
82+
// Get Queries endpoint for synced queries
83+
app.post("/get-queries", async (c) => {
84+
const authData = await getAuthData(c.req.raw);
85+
86+
return c.json(
87+
await handleGetQueriesRequest(
88+
(name, args) => getQuery(authData, name, args),
89+
schema,
90+
c.req.raw,
91+
),
92+
);
93+
});
94+
95+
const validated = Object.fromEntries(
96+
Object.values(queries).map((q) => [q.queryName, withValidation(q)])
97+
);
98+
99+
function getQuery(
100+
authData: AuthData | undefined,
101+
name: string,
102+
args: readonly ReadonlyJSONValue[],
103+
) {
104+
const q = validated[name];
105+
if (!q) {
106+
throw new Error(`No such query: ${name}`);
107+
}
108+
// withValidation returns a function that takes (context, ...args)
109+
// and returns a Query. We need to return { query: Query }
110+
return {
111+
query: q(authData, ...args),
112+
};
113+
}
114+
115+
// Push endpoint for custom mutators
116+
app.post("/push", async (c) => {
117+
const authData = await getAuthData(c.req.raw);
118+
119+
const result = await pushProcessor.process(
120+
createMutators(authData),
121+
c.req.raw,
122+
);
123+
124+
return c.json(result);
125+
});
126+
48127
export default handle(app);
49128

50129
function must<T>(val: T) {

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
"@rocicorp/zero": "0.24.2025101500",
1717
"jose": "^5.9.6",
1818
"js-cookie": "^3.0.5",
19+
"pg": "^8.16.3",
1920
"react": "^18.3.1",
2021
"react-dom": "^18.3.1",
21-
"sst": "3.9.33"
22+
"sst": "3.9.33",
23+
"zod": "^4.1.12"
2224
},
2325
"devDependencies": {
2426
"@eslint/js": "^9.9.0",

src/App.tsx

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,25 @@
1-
import { escapeLike } from "@rocicorp/zero";
21
import { useQuery, useZero } from "@rocicorp/zero/react";
32
import Cookies from "js-cookie";
43
import { useState } from "react";
54
import { formatDate } from "./date";
65
import { randInt } from "./rand";
76
import { RepeatButton } from "./repeat-button";
87
import { Schema } from "./schema";
8+
import { Mutators } from "./mutators";
99
import { randomMessage } from "./test-data";
10+
import { queries } from "./queries";
1011

1112
function App() {
12-
const z = useZero<Schema>();
13-
const [users] = useQuery(z.query.user);
14-
const [mediums] = useQuery(z.query.medium);
13+
const z = useZero<Schema, Mutators>();
14+
15+
const [users] = useQuery(queries.users());
16+
const [mediums] = useQuery(queries.mediums());
17+
const [allMessages] = useQuery(queries.messages());
1518

1619
const [filterUser, setFilterUser] = useState("");
1720
const [filterText, setFilterText] = useState("");
1821

19-
const all = z.query.message;
20-
const [allMessages] = useQuery(all);
21-
22-
let filtered = all
23-
.related("medium")
24-
.related("sender")
25-
.orderBy("timestamp", "desc");
26-
27-
if (filterUser) {
28-
filtered = filtered.where("senderID", filterUser);
29-
}
30-
31-
if (filterText) {
32-
filtered = filtered.where("body", "LIKE", `%${escapeLike(filterText)}%`);
33-
}
34-
35-
const [filteredMessages] = useQuery(filtered);
22+
const [filteredMessages] = useQuery(queries.filteredMessages(filterUser, filterText));
3623

3724
const hasFilters = filterUser || filterText;
3825

src/auth.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { jwtVerify } from "jose";
2+
import { AuthData } from "./schema";
3+
4+
export async function getAuthData(token: string | undefined): Promise<AuthData | undefined> {
5+
if (!token) {
6+
return undefined;
7+
}
8+
9+
try {
10+
const { payload } = await jwtVerify(
11+
token,
12+
new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)),
13+
);
14+
return { sub: payload.sub as string | null };
15+
} catch {
16+
return undefined;
17+
}
18+
}
19+
20+
function must<T>(val: T) {
21+
if (!val) {
22+
throw new Error("Expected value to be defined");
23+
}
24+
return val;
25+
}

src/main.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import App from "./App.tsx";
44
import "./index.css";
55
import { ZeroProvider } from "@rocicorp/zero/react";
66
import { schema } from "./schema.ts";
7+
import { createMutators } from "./mutators.ts";
78
import Cookies from "js-cookie";
89
import { decodeJwt } from "jose";
910

@@ -13,9 +14,18 @@ const userID = decodedJWT?.sub ? (decodedJWT.sub as string) : "anon";
1314
const server = import.meta.env.VITE_PUBLIC_SERVER;
1415
const auth = encodedJWT;
1516

17+
// Create auth data for mutators
18+
const authData = decodedJWT?.sub ? { sub: decodedJWT.sub as string } : { sub: null };
19+
1620
createRoot(document.getElementById("root")!).render(
1721
<StrictMode>
18-
<ZeroProvider {...{ userID, auth, server, schema }}>
22+
<ZeroProvider
23+
userID={userID}
24+
auth={auth}
25+
server={server}
26+
schema={schema}
27+
mutators={createMutators(authData)}
28+
>
1929
<App />
2030
</ZeroProvider>
2131
</StrictMode>

src/mutators.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Transaction } from "@rocicorp/zero";
2+
import { type AuthData, Schema } from "./schema";
3+
4+
export function createMutators(authData?: AuthData) {
5+
return {
6+
message: {
7+
insert: async (
8+
tx: Transaction<Schema>,
9+
args: {
10+
id: string;
11+
senderID: string;
12+
mediumID: string;
13+
body: string;
14+
labels: string[];
15+
timestamp: number;
16+
},
17+
) => {
18+
// Anyone can insert messages
19+
await tx.mutate.message.insert(args);
20+
},
21+
22+
update: async (
23+
tx: Transaction<Schema>,
24+
args: {
25+
id: string;
26+
body: string;
27+
},
28+
) => {
29+
const existing = await tx.query.message
30+
.where("id", args.id)
31+
.one()
32+
.run();
33+
34+
if (!existing) {
35+
throw new Error("Message not found");
36+
}
37+
38+
// Validate (on both client and server)
39+
if (existing.senderID !== authData?.sub) {
40+
throw new Error("Only the sender can edit this message");
41+
}
42+
43+
// Server-only validation
44+
if (tx.location === "server") {
45+
if (args.body.length > 1000) {
46+
throw new Error("Message body too long (max 1000 characters)");
47+
}
48+
}
49+
50+
await tx.mutate.message.update(args);
51+
},
52+
53+
delete: async (
54+
tx: Transaction<Schema>,
55+
args: {
56+
id: string;
57+
},
58+
) => {
59+
if (!authData?.sub) {
60+
throw new Error("Must be logged in to delete messages");
61+
}
62+
63+
await tx.mutate.message.delete(args);
64+
},
65+
},
66+
} as const;
67+
}
68+
69+
export type Mutators = ReturnType<typeof createMutators>;

src/queries.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { syncedQuery, createBuilder, escapeLike } from "@rocicorp/zero";
2+
import { z } from "zod";
3+
import { schema } from "./schema";
4+
5+
export const builder = createBuilder(schema);
6+
7+
export const queries = {
8+
users: syncedQuery(
9+
"users",
10+
z.tuple([]),
11+
() => {
12+
return builder.user.orderBy("name", "asc");
13+
}
14+
),
15+
mediums: syncedQuery(
16+
"mediums",
17+
z.tuple([]),
18+
() => {
19+
return builder.medium.orderBy("name", "asc");
20+
}
21+
),
22+
messages: syncedQuery(
23+
"messages",
24+
z.tuple([]),
25+
() => {
26+
return builder.message.orderBy("timestamp", "desc");
27+
}
28+
),
29+
filteredMessages: syncedQuery(
30+
"filteredMessages",
31+
z.tuple([
32+
z.string().optional(), // filterUser
33+
z.string().optional(), // filterText
34+
]),
35+
(filterUser, filterText) => {
36+
let query = builder.message
37+
.related("medium")
38+
.related("sender")
39+
.orderBy("timestamp", "desc");
40+
41+
if (filterUser) {
42+
query = query.where("senderID", filterUser);
43+
}
44+
45+
if (filterText) {
46+
// Note: LIKE is available in ZQL
47+
query = query.where("body", "LIKE", `%${escapeLike(filterText)}%`);
48+
}
49+
50+
return query;
51+
}
52+
)
53+
};

0 commit comments

Comments
 (0)