Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ ZERO_UPSTREAM_DB="postgresql://user:[email protected]:5430/postgres"
ZERO_AUTH_SECRET="secretkey"
ZERO_REPLICA_FILE="/tmp/hello_zero_replica.db"
ZERO_LOG_LEVEL="debug"
ZERO_GET_QUERIES_URL="http://localhost:5173/api/get-queries"
ZERO_MUTATE_URL="http://localhost:5173/api/push"
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,35 @@ createRoot(document.getElementById("root")!).render(
);
```

4. **Using Zero in Components** Example usage in React components. See
4. **Set up saved queries** See [queries.ts](src/queries.ts):

```typescript
import { savedQuery } from "@rocicorp/zero";
import { schema } from "./schema";

const builder = createBuilder(schema);

export const queries = {
users: savedQuery('users', z.tuple([]), () => {
return builder.user.orderBy('name', 'asc');
}
};
```

5. **Using Zero in Components** Example usage in React components. See
[App.tsx](src/App.tsx):

```typescript
import { useQuery, useZero } from "@rocicorp/zero/react";
import { Schema } from "./schema";
import { queries } from "./queries";

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

export function UsersPage() {
const z = useZ();
const users = useQuery(z.query.user);
const [users] = useQuery(queries.users());

if (!users) {
return null;
Expand All @@ -138,7 +154,8 @@ export function UsersPage() {
```

For more examples of queries, mutations, and relationships, explore the
[App.tsx](src/App.tsx) file in this repository.
[queries.ts](src/queries.ts) and [mutators.ts](src/mutators.ts) files in this
repository.

### Optional: Authentication

Expand Down
81 changes: 80 additions & 1 deletion api/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import { handle } from "hono/vercel";
import { SignJWT } from "jose";
import { SignJWT, jwtVerify } from "jose";
import { Pool } from "pg";
import { withValidation, ReadonlyJSONValue } from "@rocicorp/zero";
import { PushProcessor } from "@rocicorp/zero/server";
import { zeroNodePg } from "@rocicorp/zero/server/adapters/pg";
import { handleGetQueriesRequest } from "@rocicorp/zero/server";
import { AuthData, schema } from "../src/schema";
import { createMutators } from "../src/mutators";
import { queries, allUsers } from "../src/queries";

export const config = {
runtime: "edge",
};

const pool = new Pool({
connectionString: process.env.ZERO_UPSTREAM_DB,
});

const pushProcessor = new PushProcessor(zeroNodePg(schema, pool));

export const app = new Hono().basePath("/api");

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

async function getAuthData(request: Request): Promise<AuthData | undefined> {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");

if (!token) {
return undefined;
}

try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)),
);
return { sub: payload.sub as string | null };
} catch {
return undefined;
}
}


// Get Queries endpoint for synced queries
app.post("/get-queries", async (c) => {
const authData = await getAuthData(c.req.raw);

return c.json(
await handleGetQueriesRequest(
(name, args) => getQuery(authData, name, args),
schema,
c.req.raw,
),
);
});

const validated = Object.fromEntries(
Object.values(queries).map((q) => [q.queryName, withValidation(q)])
);

function getQuery(
authData: AuthData | undefined,
name: string,
args: readonly ReadonlyJSONValue[],
) {
const q = validated[name];
if (!q) {
throw new Error(`No such query: ${name}`);
}
// withValidation returns a function that takes (context, ...args)
// and returns a Query. We need to return { query: Query }
return {
query: q(authData, ...args),
};
}

// Push endpoint for custom mutators
app.post("/push", async (c) => {
const authData = await getAuthData(c.req.raw);

const result = await pushProcessor.process(
createMutators(authData),
c.req.raw,
);

return c.json(result);
});

export default handle(app);

function must<T>(val: T) {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
"@rocicorp/zero": "0.24.2025101500",
"jose": "^5.9.6",
"js-cookie": "^3.0.5",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sst": "3.9.33"
"sst": "3.9.33",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
Expand Down
29 changes: 8 additions & 21 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,25 @@
import { escapeLike } from "@rocicorp/zero";
import { useQuery, useZero } from "@rocicorp/zero/react";
import Cookies from "js-cookie";
import { useState } from "react";
import { formatDate } from "./date";
import { randInt } from "./rand";
import { RepeatButton } from "./repeat-button";
import { Schema } from "./schema";
import { Mutators } from "./mutators";
import { randomMessage } from "./test-data";
import { queries } from "./queries";

function App() {
const z = useZero<Schema>();
const [users] = useQuery(z.query.user);
const [mediums] = useQuery(z.query.medium);
const z = useZero<Schema, Mutators>();

const [users] = useQuery(queries.users());
const [mediums] = useQuery(queries.mediums());
const [allMessages] = useQuery(queries.messages());

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

const all = z.query.message;
const [allMessages] = useQuery(all);

let filtered = all
.related("medium")
.related("sender")
.orderBy("timestamp", "desc");

if (filterUser) {
filtered = filtered.where("senderID", filterUser);
}

if (filterText) {
filtered = filtered.where("body", "LIKE", `%${escapeLike(filterText)}%`);
}

const [filteredMessages] = useQuery(filtered);
const [filteredMessages] = useQuery(queries.filteredMessages(filterUser, filterText));

const hasFilters = filterUser || filterText;

Expand Down
25 changes: 25 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { jwtVerify } from "jose";
import { AuthData } from "./schema";

export async function getAuthData(token: string | undefined): Promise<AuthData | undefined> {
if (!token) {
return undefined;
}

try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(must(process.env.ZERO_AUTH_SECRET)),
);
return { sub: payload.sub as string | null };
} catch {
return undefined;
}
}

function must<T>(val: T) {
if (!val) {
throw new Error("Expected value to be defined");
}
return val;
}
12 changes: 11 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import App from "./App.tsx";
import "./index.css";
import { ZeroProvider } from "@rocicorp/zero/react";
import { schema } from "./schema.ts";
import { createMutators } from "./mutators.ts";
import Cookies from "js-cookie";
import { decodeJwt } from "jose";

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

// Create auth data for mutators
const authData = decodedJWT?.sub ? { sub: decodedJWT.sub as string } : { sub: null };

createRoot(document.getElementById("root")!).render(
<StrictMode>
<ZeroProvider {...{ userID, auth, server, schema }}>
<ZeroProvider
userID={userID}
auth={auth}
server={server}
schema={schema}
mutators={createMutators(authData)}
>
<App />
</ZeroProvider>
</StrictMode>
Expand Down
69 changes: 69 additions & 0 deletions src/mutators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Transaction } from "@rocicorp/zero";
import { type AuthData, Schema } from "./schema";

export function createMutators(authData?: AuthData) {
return {
message: {
insert: async (
tx: Transaction<Schema>,
args: {
id: string;
senderID: string;
mediumID: string;
body: string;
labels: string[];
timestamp: number;
},
) => {
// Anyone can insert messages
await tx.mutate.message.insert(args);
},

update: async (
tx: Transaction<Schema>,
args: {
id: string;
body: string;
},
) => {
const existing = await tx.query.message
.where("id", args.id)
.one()
.run();

if (!existing) {
throw new Error("Message not found");
}

// Validate (on both client and server)
if (existing.senderID !== authData?.sub) {
throw new Error("Only the sender can edit this message");
}

// Server-only validation
if (tx.location === "server") {
if (args.body.length > 1000) {
throw new Error("Message body too long (max 1000 characters)");
}
}

await tx.mutate.message.update(args);
},

delete: async (
tx: Transaction<Schema>,
args: {
id: string;
},
) => {
if (!authData?.sub) {
throw new Error("Must be logged in to delete messages");
}

await tx.mutate.message.delete(args);
},
},
} as const;
}

export type Mutators = ReturnType<typeof createMutators>;
53 changes: 53 additions & 0 deletions src/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { syncedQuery, createBuilder, escapeLike } from "@rocicorp/zero";
import { z } from "zod";
import { schema } from "./schema";

export const builder = createBuilder(schema);

export const queries = {
users: syncedQuery(
"users",
z.tuple([]),
() => {
return builder.user.orderBy("name", "asc");
}
),
mediums: syncedQuery(
"mediums",
z.tuple([]),
() => {
return builder.medium.orderBy("name", "asc");
}
),
messages: syncedQuery(
"messages",
z.tuple([]),
() => {
return builder.message.orderBy("timestamp", "desc");
}
),
filteredMessages: syncedQuery(
"filteredMessages",
z.tuple([
z.string().optional(), // filterUser
z.string().optional(), // filterText
]),
(filterUser, filterText) => {
let query = builder.message
.related("medium")
.related("sender")
.orderBy("timestamp", "desc");

if (filterUser) {
query = query.where("senderID", filterUser);
}

if (filterText) {
// Note: LIKE is available in ZQL
query = query.where("body", "LIKE", `%${escapeLike(filterText)}%`);
}

return query;
}
)
};
Loading