Skip to content

Commit

Permalink
eval: actions run_query_mutation
Browse files Browse the repository at this point in the history
  • Loading branch information
ianmacartney committed Jan 31, 2025
1 parent d50f1d6 commit 66b2c2f
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 0 deletions.
5 changes: 5 additions & 0 deletions evals/004-actions/002-run_query_mutation/GAPS.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
actions, run_query_mutation:

- Struggling with the return type on handler to break type cycles.
- Struggling with api.saveFetchResult instead of api.index.saveFetchResult.
- Not using an internal query/mutation, but not really asked to.
47 changes: 47 additions & 0 deletions evals/004-actions/002-run_query_mutation/TASK.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
Create a backend that conditionally fetches and caches external data.

Create this schema in `convex/schema.ts`:
```ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
fetchRequests: defineTable({
url: v.string(),
data: v.any(),
}).index("by_url", ["url"]),
});
```

Implement these functions in `convex/index.ts`:

1. Create a query `getFetchResult` that:
- Takes a url string as argument
- Uses the "by_url" index to look up any existing fetch result
- Returns the ID of the record if found, null if not found

2. Create a mutation `saveFetchResult` that:
- Takes url (string) and data (any) as arguments
- Inserts a new record with the current timestamp, or updates an existing record if the URL already exists
- Has the handler return type of `Promise<Id<"fetchResults">>`
- Returns the ID of the new record

3. Create an action `fetchIfNeeded` that uses the query and mutation to:
- Takes a url string as argument
- Makes a fetch request to the URL, if the result is not already cached in fetchResults.
- If it isn't cached, write the JSON response to the fetchResults table
- Has the handler return type of `Promise<Id<"fetchResults">>`
- Returns the newly created record ID

Add appropriate ESLint directives for any type handling:
- `// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment`
- `// eslint-disable-next-line @typescript-eslint/no-unsafe-return`

The implementation should demonstrate:
- Proper use of indexes for efficient lookups
- Coordination between query, mutation and action
- Proper type handling for external data

Create only the `convex/schema.ts`, `convex/index.ts`, and `package.json` files. Do not generate any other files.

Do not export any functions from `convex/index.ts` other than `getFetchResult`, `saveFetchResult`, and `fetchIfNeeded`.
68 changes: 68 additions & 0 deletions evals/004-actions/002-run_query_mutation/answer/bun.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "convexbot",
"dependencies": {
"convex": "^1.17.4",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],

"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g=="],

"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ=="],

"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ=="],

"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow=="],

"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ=="],

"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw=="],

"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ=="],

"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw=="],

"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw=="],

"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA=="],

"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A=="],

"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w=="],

"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw=="],

"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw=="],

"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg=="],

"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ=="],

"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw=="],

"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ=="],

"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg=="],

"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA=="],

"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ=="],

"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA=="],

"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g=="],

"convex": ["[email protected]", "", { "dependencies": { "esbuild": "0.23.0", "jwt-decode": "^3.1.2", "prettier": "3.4.2" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0-0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react", "react-dom"], "bin": { "convex": "bin/main.js" } }, "sha512-a9VVy9Ss7Z5Twt80t0yekyB4CWUWB3EpH5aru5MxqD8EX2QLnu9lNtsTc+eKM1V1CTVvC3LuYaBWBdcvFqMsYQ=="],

"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.23.0", "@esbuild/android-arm": "0.23.0", "@esbuild/android-arm64": "0.23.0", "@esbuild/android-x64": "0.23.0", "@esbuild/darwin-arm64": "0.23.0", "@esbuild/darwin-x64": "0.23.0", "@esbuild/freebsd-arm64": "0.23.0", "@esbuild/freebsd-x64": "0.23.0", "@esbuild/linux-arm": "0.23.0", "@esbuild/linux-arm64": "0.23.0", "@esbuild/linux-ia32": "0.23.0", "@esbuild/linux-loong64": "0.23.0", "@esbuild/linux-mips64el": "0.23.0", "@esbuild/linux-ppc64": "0.23.0", "@esbuild/linux-riscv64": "0.23.0", "@esbuild/linux-s390x": "0.23.0", "@esbuild/linux-x64": "0.23.0", "@esbuild/netbsd-x64": "0.23.0", "@esbuild/openbsd-arm64": "0.23.0", "@esbuild/openbsd-x64": "0.23.0", "@esbuild/sunos-x64": "0.23.0", "@esbuild/win32-arm64": "0.23.0", "@esbuild/win32-ia32": "0.23.0", "@esbuild/win32-x64": "0.23.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA=="],

"jwt-decode": ["[email protected]", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],

"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
}
}
65 changes: 65 additions & 0 deletions evals/004-actions/002-run_query_mutation/answer/convex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { internalQuery, internalMutation, action } from "./_generated/server";
import { v } from "convex/values";
import { api, internal } from "./_generated/api";
import { Id } from "./_generated/dataModel";

export const getFetchResult = internalQuery({
args: { url: v.string() },
handler: async (ctx, args): Promise<Id<"fetchRequests"> | null> => {
const result = await ctx.db
.query("fetchRequests")
.withIndex("by_url", (q) => q.eq("url", args.url))
.first();
return result?._id ?? null;
},
});

export const saveFetchResult = internalMutation({
args: {
url: v.string(),
data: v.any(),
},
handler: async (ctx, args): Promise<Id<"fetchRequests">> => {
const existing = await ctx.db
.query("fetchRequests")
.withIndex("by_url", (q) => q.eq("url", args.url))
.first();
if (existing) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
await ctx.db.patch(existing._id, { data: args.data });
return existing._id;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { url, data } = args;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const id = await ctx.db.insert("fetchRequests", { url, data });
return id;
},
});

export const fetchIfNeeded = action({
args: { url: v.string() },
handler: async (ctx, args): Promise<Id<"fetchRequests">> => {
const existing = await ctx.runQuery(internal.index.getFetchResult, { url: args.url });

if (existing) {
return existing;
}

const response = await fetch(args.url);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = await response.json();

if (!response.ok) {
throw new Error(`Failed to fetch ${args.url}: ${response.statusText}`);
}

const id = await ctx.runMutation(internal.index.saveFetchResult, {
url: args.url,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data,
});

return id;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
fetchRequests: defineTable({
url: v.string(),
data: v.any(),
}).index("by_url", ["url"]),
});
7 changes: 7 additions & 0 deletions evals/004-actions/002-run_query_mutation/answer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "convexbot",
"version": "1.0.0",
"dependencies": {
"convex": "^1.17.4"
}
}
116 changes: 116 additions & 0 deletions evals/004-actions/002-run_query_mutation/grader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { expect, test } from "vitest";
import {
responseAdminClient,
responseClient,
compareSchema,
compareFunctionSpec,
deleteAllDocuments,
listTable,
} from "../../../grader";
import { api } from "./answer/convex/_generated/api";
import { beforeEach } from "vitest";
import { Doc } from "./answer/convex/_generated/dataModel";

beforeEach(async () => {
await deleteAllDocuments(responseAdminClient, ["fetchRequests"]);
});

test("compare schema", async ({ skip }) => {
await compareSchema(skip);
});

test("compare function spec", async ({ skip }) => {
await compareFunctionSpec(skip);
});

test("fetchIfNeeded caches new requests", async () => {
const testUrl = "https://httpbin.org/json";

// First request should fetch and cache
const id1 = await responseClient.action(api.index.fetchIfNeeded, {
url: testUrl,
});
expect(id1).toBeDefined();

// Check the cached data
const results = (await listTable(responseAdminClient, "fetchRequests")) as Doc<"fetchRequests">[];
expect(results).toHaveLength(1);
expect(results[0].url).toBe(testUrl);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(results[0].data.slideshow).toBeDefined();
});

test("fetchIfNeeded reuses cached results", async () => {
const testUrl = "https://httpbin.org/json";

// Make two requests to the same URL
const id1 = await responseClient.action(api.index.fetchIfNeeded, {
url: testUrl,
});
const id2 = await responseClient.action(api.index.fetchIfNeeded, {
url: testUrl,
});

// Should return the same ID
expect(id1).toBe(id2);

// Should only have one cached result
const results = await listTable(responseAdminClient, "fetchRequests");
expect(results).toHaveLength(1);
});

test("fetchIfNeeded handles different URLs separately", async () => {
const urls = [
"https://httpbin.org/json",
"https://httpbin.org/get",
];

// Fetch both URLs
const ids = await Promise.all(
urls.map(async url => responseClient.action(api.index.fetchIfNeeded, { url }))
);

// Should get different IDs
expect(ids[0]).not.toBe(ids[1]);

// Should have two cached results
const results = (await listTable(responseAdminClient, "fetchRequests")) as Doc<"fetchRequests">[];
expect(results).toHaveLength(2);

// Verify different data structures were cached
const resultsByUrl = new Map(results.map(r => [r.url, r]));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(resultsByUrl.get(urls[0])?.data.slideshow).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(resultsByUrl.get(urls[1])?.data.url).toBeDefined();
});

test("handles concurrent requests to same URL", async () => {
const testUrl = "https://httpbin.org/json";

// Make multiple concurrent requests
const ids = await Promise.all([
responseClient.action(api.index.fetchIfNeeded, { url: testUrl }),
responseClient.action(api.index.fetchIfNeeded, { url: testUrl }),
responseClient.action(api.index.fetchIfNeeded, { url: testUrl }),
]);

// All requests should return the same ID
expect(new Set(ids).size).toBe(1);

// Should only have one cached result
const results = await listTable(responseAdminClient, "fetchRequests");
expect(results).toHaveLength(1);
});

test("handles invalid URLs appropriately", async () => {
const invalidUrl = "https://invalid-url-that-does-not-exist.example.com";

await expect(
responseClient.action(api.index.fetchIfNeeded, { url: invalidUrl })
).rejects.toThrow();

// Should not cache failed requests
const results = await listTable(responseAdminClient, "fetchRequests");
expect(results).toHaveLength(0);
});

0 comments on commit 66b2c2f

Please sign in to comment.