Share
Embed
GraphQL, Pothos & SQLite: a perfect match
Exploring how GraphQL and SQLite give us a strong foundation for building production applications, with a great developer experience.
Matthias Le Brun
December 11, 2025
Transcript
46 slides
-
Matthias Le Brun software engineer lead AI hater chief shitpost
of fi cer who am I Paris.JS organizer ReScript core team Putain de Code co-founder bsky: @bloodyowl.io -
query App { viewer { id profile { avatarUrl posts(first:
10) { id content } } } } -
query App { viewer { id profile { avatarUrl posts(first:
10) { id content } } } } SELECT * FROM user WHERE user_id = ? -
query App { viewer { id profile { avatarUrl posts(first:
10) { id content } } } } SELECT * FROM user_profile WHERE user_profile_id = ? -
query App { viewer { id profile { avatarUrl posts(first:
10) { id content } } } } SELECT * FROM user_profile_posts WHERE user_profile_id = ? ORDER BY created_at DESC -
database network mitigations 1. data-loader cache and group by depth
2. over-fetch based on common access patterns 3. add cache layer between the application and the database -
fi g. 1: modern software architecture aka «turn function calls
into HTTP requests, get billed 3x the cost by AWS, hire dozens of people just to manage the complexity» -
database SQLite aka: "you probably won't need more for quite
a while" has the reputation of being a toy database because we don't reassess technology when hardware evolves dramatically (ie. SSD being slightly faster than hard drives) -
SQLite WAL Write Ahead Logging understand: reads and writes no
longer block each other -
SQLite limitations → size of your physical storage → hard
limit on 281TB for a single database → no long synchronous transactions → 200K read/s → 50K writes/s table with indexes, M1 chip (requires a few fl ags) -
disaster recovery service to run on your server that incrementally
backs up your database over S3, SFTP -
what is pothos? → schema builder → built over graphql-js
→ fully type-safe → plugins -
1. de fi ne a context type export type RequestContext
= { sql: SQL auth: AuthContext | undefined logger: Logger t: Translator req: BunRequest<string> } -
2. create a schema builder const GraphqlSchemaBuilder = new SchemaBuilder<{
Context: RequestContext // ... and de fi ne its context type -
4. de fi ne some GraphQL types const userStatusValues =
{ Active: { value: "active" }, Deactivated: { value: "deactivated" }, } satisfies Record<string, { value: UserStatus }> export const GraphqlUserStatus = GraphqlSchemaBuilder.enumType("UserStatus", { values: userStatusValues, }) -
5. de fi ne some GraphQL object refs export const
GraphqlUserRef = GraphqlSchemaBuilder .loadableObjectRef<User, string>("User", { load: async (ids: Array<string>, context) => getManyUsersByIds(ids, context.sql), sort: (user) => user.user_id, }) refs represent the object where dependencies don't become circular in ESM data-loader plugin makes it trivial, you can either resolve from the object model or its identi fi er -
6. implement GraphQL object export const GraphqlUser = GraphqlUserRef.implement({ fields:
(t) => ({ id: t.exposeID("user_id", { nullable: false }), createdAt: t.field({ type: "DateTime", nullable: false, resolve: (user) => new Date(user.created_at), }), fullName: t.exposeString("full_name", { nullable: false }), email: t.exposeString("email", { nullable: false }), status: t.expose("status", { type: GraphqlUserStatus, nullable: false }), }), }) -
7. expose your object somewhere GraphqlSchemaBuilder.queryField("viewer", (t) => t.field({ type:
GraphqlUser, // we don't use auth scopes there in order for the auth process to check // without error if there's a currently logged viewer resolve: (parent, args, context) => context.auth?.type === "User" ? context.auth.userId : null, }), ) -
niceties: error management GraphqlSchemaBuilder.objectType(CloseAccountError, { name: "CloseAccountError", interfaces: [GraphqlError], fields:
(t) => ({ message: t.string({ nullable: false, resolve: (parent, args, { t }) => t("api.CloseAccountError.message"), }), }), }) map an error subclass to an object type -
niceties: error management GraphqlSchemaBuilder.mutationField("closeAccount", (t) => t.withAuth({ user: true }).field({
type: GraphqlSensitiveOperationRef, errors: { types: [CloseAccountError], dataField: { name: "sensitiveOperation", }, }, // ... de fi ne "domain errors" in mutation fi elds -
niceties: error management union CloseAccount = | CloseAccountError | CloseAccountSuccess
| ForbiddenError | UnexpectedError | ValidationError type Mutation { # ... closeAccount(input: CloseAccountInput!): CloseAccount # ... } get domain errors in your schema contract -
niceties: permissions management canViewAccount: async (accountId) => { if (accountId
== undefined) { return true } if (context.auth != null && context.auth.type === "User") { const member = await context.accessControl.getAccountMember({ userId: context.auth.userId, accountId, }) return ( member?.status === "active" && member?.can_view_account_bool === true ) } return false }, // ... de fi ne custom permission checks with arguments -
niceties: permissions management // ... number: t.exposeString("account_number", { nullable: false,
authScopes: (parent) => ({ $any: { canViewAccount: parent.account_id, permission: "accounts_read", }, }), }), // ... fi lter on $any or $all strategies -
other plugins → federation → subgraphs → zod validation →
relay → prisma -
performance 20 columns table with 1M rows, no optimization, on
a single core (Intel Xeon E3 1220) on a 30€/month machine query AccountTransfers( ... ) { __typename account(input: $account) { __typename id transactions(first: 10) { __typename pageInfo { __typename hasNextPage endCursor } edges { __typename node { __typename # ... } } } } -
testing how about having tests that verify behavior? and ideally
without having to instantiate 30 modules per suite -
testing comfort → consuming a GraphQL API → simple assertions
→ isolated run → fl at -
testing comfort: consuming GQL gql.tada TypeScript plugin, works without extra
compilation step (some commands needed to cache when having a lot of occurrences though) -
testing comfort: consuming GQL const closeAccountMutation = api.gql( /* gql
*/ ` mutation CloseAccount($input: CloseAccountInput!) { closeAccount(input: $input) { __typename ... on CloseAccountSuccess { sensitiveOperation { id url } } ... on Error { message } } } `) inlined in your tests! -
testing comfort: isolation const sql = new SQL(":memory:", { safeIntegers:
true }) embedded databases make isolation virtually free (PGLite also does a great job with a decent performance penalty) -
test("Close account", async () => { using api = testApi()
const accountId = await createTestAccount(api) // ... const account = await api.query( accountQuery, { input: { id: accountId }, first: 10, }, api.tester, ) assertIsDefined(account.account) assertIsDefined(account.account.iban) }) new app context, cleans up at the end of the function execution assertions re fi ne the type of the value for subsequent lines -
conclusion 1. reassess technology when hardware evolves also don't let
the cloud make us forget that it's just computers underneath -
conclusion 2. tradeoffs are contextual over-anticipation can kill velocity, decide
for a near future, not a distant one -
conclusion 3. developer experience makes quality easier to get reduce
accidental complexity, focus on the real one -
Matthias Le Brun frontend engineer lead AI hater chief shitpost
of fi cer thank you! Paris.JS organizer ReScript core team Putain de Code co-founder bsky: @bloodyowl.io