Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/seven-moose-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/better-auth": minor
---

Change underlying fetch implementation
4 changes: 2 additions & 2 deletions packages/better-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@
"dependencies": {
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@better-fetch/fetch": "1.1.17",
"@better-fetch/logger": "^1.1.18",
"@commander-js/extra-typings": "^14.0.0",
"@tanstack/vite-config": "^0.2.0",
"better-auth": "^1.2.10",
Expand All @@ -57,13 +55,15 @@
"dotenv": "^16.5.0",
"fs-extra": "^11.3.0",
"neverthrow": "^8.2.0",
"odata-query": "^8.0.4",
"prompts": "^2.4.2",
"vite": "^6.3.4",
"zod": "3.25.64"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/prompts": "^2.4.9",
"@vitest/ui": "^3.2.4",
"fm-odata-client": "^3.0.1",
"publint": "^0.3.12",
"typescript": "^5.9.2",
Expand Down
186 changes: 125 additions & 61 deletions packages/better-auth/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {
createAdapter,
type AdapterDebugLogs,
} from "better-auth/adapters";
import { createFmOdataFetch, type FmOdataConfig } from "./odata";
import { createRawFetch, type FmOdataConfig } from "./odata";
import { prettifyError, z } from "zod/v4";
import { logger } from "better-auth";
import buildQuery from "odata-query";

const configSchema = z.object({
debugLogs: z.unknown().optional(),
Expand Down Expand Up @@ -153,7 +154,7 @@ export const FileMakerAdapter = (
}
const config = parsed.data;

const fetch = createFmOdataFetch({
const { fetch, baseURL } = createRawFetch({
...config.odata,
logging: config.debugLogs ? "verbose" : "none",
});
Expand Down Expand Up @@ -192,114 +193,177 @@ export const FileMakerAdapter = (
count: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
const result = await fetch(`/${model}/$count`, {

const query = buildQuery({
filter: filter.length > 0 ? filter : undefined,
});

const result = await fetch(`/${model}/$count${query}`, {
method: "GET",
query: {
$filter: filter,
},
output: z.object({ value: z.number() }),
});
if (!result.data) {
throw new Error("Failed to count records");
}
return result.data?.value ?? 0;
return (result.data?.value as any) ?? 0;
},
findOne: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
const result = await fetch(`/${model}`, {

const query = buildQuery({
top: 1,
filter: filter.length > 0 ? filter : undefined,
});

const result = await fetch(`/${model}${query}`, {
method: "GET",
query: {
...(filter.length > 0 ? { $filter: filter } : {}),
$top: 1,
},
output: z.object({ value: z.array(z.any()) }),
});
if (result.error) {
throw new Error("Failed to find record");
}
return result.data?.value?.[0] ?? null;
return (result.data?.value?.[0] as any) ?? null;
},
findMany: async ({ model, where, limit, offset, sortBy }) => {
const filter = parseWhere(where);
logger.debug("$filter", filter);
logger.debug("FIND MANY", { where, filter });

const query = buildQuery({
top: limit,
skip: offset,
orderBy: sortBy
? `${sortBy.field} ${sortBy.direction ?? "asc"}`
: undefined,
filter: filter.length > 0 ? filter : undefined,
});
logger.debug("QUERY", query);

Comment on lines +232 to 241
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Confirm odata-query accepts raw string for filter/orderBy.

You're passing a prebuilt filter string and a raw 'field dir' string to buildQuery. Some versions of odata-query expect structured inputs. Verify compatibility to avoid silent query mishandling.


🌐 Web query:

Does the 'odata-query' package accept a raw string for 'filter' and 'orderBy' (e.g., filter: "Foo eq 1" and orderBy: "Foo asc") when calling buildQuery?

💡 Result:

The odata-query package does not accept raw OData strings directly for the filter or orderBy parameters when calling buildQuery. Instead, both parameters expect an object (for filter) or an array/object (for orderBy), which the library then converts into OData query syntax.

Details and Examples:

  • filter parameter:

    • You cannot simply pass filter: "Foo eq 1".
    • You must use an object or a supported OData-query "expression object." For example:
      const filter = { Foo: 1 }; // Becomes 'Foo eq 1'
      buildQuery({ filter }); // => '?$filter=Foo eq 1'
    • You may mix object notation with limited custom string snippets in logical groups, but only inside operators (and, or, not). For example:
      const filter = {
        and: [
          { Foo: 1 },
          'startswith(Name, "foo")'
        ]
      };
      buildQuery({ filter }); // => '?$filter=Foo eq 1 and startswith(Name, "foo")'
      Raw strings are only allowed inside logical group arrays—not as the top-level filter[3].
  • orderBy parameter:

    • You cannot simply pass orderBy: "Foo asc".
    • You must use an array or an object notation describing the order, such as:
      buildQuery({ orderBy: [{ Foo: 'asc' }] }); // => '?$orderby=Foo asc'
      or
      buildQuery({ orderBy: { Foo: 'asc' } }); // => '?$orderby=Foo asc'
      The value can also be 'desc' for descending sort[3].

Conclusion:

  • Raw filter and orderBy strings are not supported as top-level values.
  • You must use the object or array syntax provided by the library. Limited use of raw snippets is possible for complex case filters, but never directly as the filter/orderBy root value[3].
  • This reflects the goal of the package: to help avoid hand-written OData strings and provide type safety.

For reference, see the npm documentation for [odata-query][3].

If you need to use a fully raw filter/orderBy string, you must build the full query string manually or use another library that supports direct string injection.


Key facts:

  • buildQuery({ filter: "Foo eq 1" }) → ❌ Not supported
  • buildQuery({ orderBy: "Foo asc" }) → ❌ Not supported
  • buildQuery({ filter: { Foo: 1 } }) → ✅ Supported
  • buildQuery({ orderBy: [{ Foo: 'asc' }] }) → ✅ Supported

[3]

Citations:


Use structured filter and orderBy inputs with buildQuery

The odata-query package does not accept raw OData strings as the top-level values for filter or orderBy. Passing "Foo eq 1" or "Foo asc" directly will be ignored or mis-serialized. You must supply an object (for filter) or an object/array (for orderBy) so the library can generate valid query syntax.

· packages/better-auth/src/adapter.ts (around lines 232–241):

  • Replace the raw filter array with a structured expression (e.g. { and: filter }).
  • Replace the raw “field dir” string for orderBy with an array or object mapping field to direction.

Suggested diff:

           const query = buildQuery({
             top: limit,
             skip: offset,
-            orderBy: sortBy
-              ? `${sortBy.field} ${sortBy.direction ?? "asc"}`
-              : undefined,
-            filter: filter.length > 0 ? filter : undefined,
+            orderBy: sortBy
+              ? [ { [sortBy.field]: sortBy.direction ?? "asc" } ]
+              : undefined,
+            filter: filter.length > 0
+              ? { and: filter }
+              : undefined,
           });
           logger.debug("QUERY", query);

This ensures your filters and sort orders are serialized properly (e.g. ?$filter=Foo eq 1 and ...&$orderby=Foo asc).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/better-auth/src/adapter.ts around lines 232 to 241, update the
buildQuery inputs so you pass structured objects instead of raw OData strings:
wrap the filter array as a logical expression (e.g. pass filter: filter.length ?
{ and: filter } : undefined) and convert the sortBy string into an object/array
form that odata-query expects (e.g. orderBy: sortBy ? [{ [sortBy.field]:
(sortBy.direction ?? "asc") }] : undefined); keep top/skip as-is and preserve
the debug log.

const rows = await fetch(`/${model}`, {
const result = await fetch(`/${model}${query}`, {
method: "GET",
query: {
...(filter.length > 0 ? { $filter: filter } : {}),
$top: limit,
$skip: offset,
...(sortBy
? { $orderby: `"${sortBy.field}" ${sortBy.direction ?? "asc"}` }
: {}),
},
output: z.object({ value: z.array(z.any()) }),
});
if (rows.error) {
logger.debug("RESULT", result);

if (result.error) {
throw new Error("Failed to find records");
}
return rows.data?.value ?? [];

return (result.data?.value as any) ?? [];
},
delete: async ({ model, where }) => {
const filter = parseWhere(where);
console.log("DELETE", { model, where, filter });
logger.debug("$filter", filter);
console.log("delete", model, where, filter);
const result = await fetch(`/${model}`, {

// Find a single id matching the filter
const query = buildQuery({
top: 1,
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});

const toDelete = await fetch(`/${model}${query}`, {
method: "GET",
output: z.object({ value: z.array(z.object({ id: z.string() })) }),
});

const id = toDelete.data?.value?.[0]?.id;
if (!id) {
// Nothing to delete
return;
}

const result = await fetch(`/${model}('${id}')`, {
method: "DELETE",
query: {
...(where.length > 0 ? { $filter: filter } : {}),
$top: 1,
},
});
if (result.error) {
console.log("DELETE ERROR", result.error);
throw new Error("Failed to delete record");
}
},
deleteMany: async ({ model, where }) => {
const filter = parseWhere(where);
logger.debug(
where
.map((o) => `typeof ${o.value} is ${typeof o.value}`)
.join("\n"),
);
logger.debug("$filter", filter);
console.log("DELETE MANY", { model, where, filter });

const result = await fetch(`/${model}/$count`, {
method: "DELETE",
query: {
...(where.length > 0 ? { $filter: filter } : {}),
},
output: z.coerce.number(),
// Find all ids matching the filter
const query = buildQuery({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
if (result.error) {
throw new Error("Failed to delete record");

const rows = await fetch(`/${model}${query}`, {
method: "GET",
output: z.object({ value: z.array(z.object({ id: z.string() })) }),
});

const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
let deleted = 0;
for (const id of ids) {
const res = await fetch(`/${model}('${id}')`, {
method: "DELETE",
});
if (!res.error) deleted++;
}
return result.data ?? 0;
return deleted;
},
update: async ({ model, where, update }) => {
const result = await fetch(`/${model}`, {
const filter = parseWhere(where);
logger.debug("UPDATE", { model, where, update });
logger.debug("$filter", filter);
// Find one id to update
const query = buildQuery({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});

const existing = await fetch(`/${model}${query}`, {
method: "GET",
output: z.object({ value: z.array(z.object({ id: z.string() })) }),
});
logger.debug("EXISTING", existing.data);

const id = existing.data?.value?.[0]?.id;
if (!id) return null;

const patchRes = await fetch(`/${model}('${id}')`, {
method: "PATCH",
query: {
...(where.length > 0 ? { $filter: parseWhere(where) } : {}),
$top: 1,
$select: [`"id"`],
},
body: update,
output: z.object({ value: z.array(z.any()) }),
});
return result.data?.value?.[0] ?? null;
logger.debug("PATCH RES", patchRes.data);
if (patchRes.error) return null;

// Read back the updated record
const readBack = await fetch(`/${model}('${id}')`, {
method: "GET",
output: z.record(z.string(), z.unknown()),
});
logger.debug("READ BACK", readBack.data);
return (readBack.data as any) ?? null;
},
updateMany: async ({ model, where, update }) => {
const filter = parseWhere(where);
const result = await fetch(`/${model}`, {
method: "PATCH",
query: {
...(where.length > 0 ? { $filter: filter } : {}),
},
body: update,
// Find all ids matching the filter
const query = buildQuery({
select: [`"id"`],
filter: filter.length > 0 ? filter : undefined,
});
return result.data as any;

const rows = await fetch(`/${model}${query}`, {
method: "GET",
output: z.object({ value: z.array(z.object({ id: z.string() })) }),
});

const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
let updated = 0;
for (const id of ids) {
const res = await fetch(`/${model}('${id}')`, {
method: "PATCH",
body: update,
});
if (!res.error) updated++;
}
return updated as any;
},
};
},
Expand Down
5 changes: 3 additions & 2 deletions packages/better-auth/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { logger } from "better-auth";
import prompts from "prompts";
import chalk from "chalk";
import { AdapterOptions } from "../adapter";
import { createFmOdataFetch } from "../odata";
import { createRawFetch } from "../odata";
import "dotenv/config";

async function main() {
Expand Down Expand Up @@ -64,7 +64,7 @@ async function main() {
const betterAuthSchema = getAuthTables(config);

const adapterConfig = (adapter.options as AdapterOptions).config;
const fetch = createFmOdataFetch({
const { fetch } = createRawFetch({
...adapterConfig.odata,
auth:
// If the username and password are provided in the CLI, use them to authenticate instead of what's in the config file.
Expand All @@ -74,6 +74,7 @@ async function main() {
password: options.password,
}
: adapterConfig.odata.auth,
logging: "verbose", // Enable logging for CLI operations
});

const migrationPlan = await planMigration(
Expand Down
36 changes: 29 additions & 7 deletions packages/better-auth/src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { type BetterAuthDbSchema } from "better-auth/db";
import { type Metadata } from "fm-odata-client";
import chalk from "chalk";
import z from "zod/v4";
import { createFmOdataFetch } from "./odata";
import { createRawFetch } from "./odata";

export async function getMetadata(
fetch: ReturnType<typeof createFmOdataFetch>,
fetch: ReturnType<typeof createRawFetch>["fetch"],
databaseName: string,
) {
console.log("getting metadata...");
Expand All @@ -21,11 +21,16 @@ export async function getMetadata(
.catch(null),
});

if (result.error) {
console.error("Failed to get metadata:", result.error);
return null;
}

return (result.data?.[databaseName] ?? null) as Metadata | null;
}

export async function planMigration(
fetch: ReturnType<typeof createFmOdataFetch>,
fetch: ReturnType<typeof createRawFetch>["fetch"],
betterAuthSchema: BetterAuthDbSchema,
databaseName: string,
): Promise<MigrationPlan> {
Expand Down Expand Up @@ -156,24 +161,41 @@ export async function planMigration(
}

export async function executeMigration(
fetch: ReturnType<typeof createFmOdataFetch>,
fetch: ReturnType<typeof createRawFetch>["fetch"],
migrationPlan: MigrationPlan,
) {
for (const step of migrationPlan) {
if (step.operation === "create") {
console.log("Creating table:", step.tableName);
await fetch("@post/FileMaker_Tables", {
const result = await fetch("/FileMaker_Tables", {
method: "POST",
body: {
tableName: step.tableName,
fields: step.fields,
},
});

if (result.error) {
console.error(
`Failed to create table ${step.tableName}:`,
result.error,
);
throw new Error(`Migration failed: ${result.error}`);
}
} else if (step.operation === "update") {
console.log("Adding fields to table:", step.tableName);
await fetch("@post/FileMaker_Tables/:tableName", {
params: { tableName: step.tableName },
const result = await fetch(`/FileMaker_Tables/${step.tableName}`, {
method: "PATCH",
body: { fields: step.fields },
});

if (result.error) {
console.error(
`Failed to update table ${step.tableName}:`,
result.error,
);
throw new Error(`Migration failed: ${result.error}`);
}
}
}
}
Expand Down
Loading