LakeQL's extension points let you replace built-in behavior with custom implementations. The primary customization targets are the authentication resolver, permission resolvers, and scalar types.
Custom GetUserResolver #
Replace the built-in mock auth with your own JWT verification:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import type { GetUserResolver } from "@lakeql/api/types"
import { jwtVerify } from "jose"
export const customGetUser: GetUserResolver = async (req) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "")
if (!token) return null
try {
const { payload } = await jwtVerify(token, secretKey)
return { ...payload, userName: payload.sub ?? "unknown" }
} catch {
return null
}
}
Custom ReadPermissionResolver #
Override how read permissions are evaluated:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21import type { ReadPermissionResolver } from "@lakeql/api/types"
export const customReadPermission: ReadPermissionResolver = ({
context,
catalog,
schema,
tableName,
}) => {
// Allow all reads for admin users
if (context.currentUser?.role === "admin") {
return true
}
// Check against external policy engine
return checkPolicy(context.currentUser, "read", {
catalog,
schema,
tableName,
})
}
Custom WritePermissionResolver #
Override how write permissions are evaluated:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import type { WritePermissionResolver } from "@lakeql/api/types"
export const customWritePermission: WritePermissionResolver = ({
context,
catalog,
schema,
tableName,
}) => {
// Only service accounts can write
if (!context.currentUser?.isServiceAccount) {
return false
}
return checkPolicy(context.currentUser, "write", {
catalog,
schema,
tableName,
})
}
Registering custom resolvers #
Pass all custom resolvers via defineConfig:
1
2
3
4
5
6
7
8
9
10
11
12
import { defineConfig } from "@lakeql/api/config"
import { allConfigs } from "./generated/configs"
import { customGetUser } from "./auth"
import { customReadPermission, customWritePermission } from "./permissions"
export default defineConfig({
allConfigs,
getUser: customGetUser,
hasReadPermission: customReadPermission,
hasWritePermission: customWritePermission,
})
Adding custom scalars #
Register additional scalars on the shared builder in a query-schema.ts file. Place it in a directory that sorts alphabetically before your other schemas (e.g., 00-scalars/) to ensure the scalars are available when other schema files are loaded.
1
2
3
4
5
6
import { builder } from "@lakeql/api/builder"
import { JSONResolver, BigIntResolver } from "graphql-scalars"
builder.addScalarType("JSON", JSONResolver, {})
builder.addScalarType("BigInt", BigIntResolver, {})
graphql and graphql-scalars are transitive dependencies of @lakeql/api.
To avoid issues if those internals ever change, install them explicitly in
your project.
npm install graphql graphql-scalarsThese scalars become available in all other schema files loaded after this one. Place scalar definitions in a directory that sorts alphabetically before other schemas (e.g., 00-scalars/) to ensure they're loaded first.
Overriding built-in scalar serialization #
GraphQL's built-in Int scalar only accepts 32-bit signed integers (max ~2.1 billion). Both Hive/Trino INT and BIGINT columns are mapped to the Integer field type by the pull command, so values beyond the 32-bit range can reach GraphQL at runtime. When that happens, the default Int.serialize throws a range error.
You can monkey-patch the serializer at startup to handle these edge cases.
Setup #
Create a query-schema.ts file in a directory that sorts alphabetically before your other schemas (e.g., 00-scalars/). LakeQL loads all query-schema.{ts,js,mjs} and mutation-schema.{ts,js,mjs} files in alphabetical directory order, so the override will be active before any resolvers execute.
1
2
3
4
5
6
7
8
src/
└── schemas/
├── 00-scalars/
│ └── query-schema.ts ← Int override goes here
├── my-catalog/
│ └── ...
└── ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import { GraphQLInt } from "graphql"
// Preserve the original serializer as a fallback
const originalIntSerialize = GraphQLInt.serialize.bind(GraphQLInt)
GraphQLInt.serialize = (value: unknown) => {
// Pass through any safe integer value directly, even beyond 32-bit range
if (
typeof value === "number" &&
Number.isInteger(value) &&
Number.isFinite(value)
) {
return value
}
// Handle numeric strings (e.g., from database drivers returning string IDs)
if (typeof value === "string" && value !== "") {
const num = Number(value)
if (Number.isInteger(num) && Number.isFinite(num)) return num
}
// Fall back to default behavior for anything else
return originalIntSerialize(value)
}
Since this file is named query-schema.ts and lives in 00-scalars/, LakeQL will import it automatically — no additional configuration or explicit import is needed.