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
27 changes: 19 additions & 8 deletions packages/better-auth/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node --no-warnings
import { Command } from "@commander-js/extra-typings";
import type { Database } from "@proofkit/fmodata";
import type { Database, FFetchOptions } from "@proofkit/fmodata";
import { FMServerConnection } from "@proofkit/fmodata";
import { logger } from "better-auth";
import { getAdapter, getSchema } from "better-auth/db";
Expand Down Expand Up @@ -67,12 +67,15 @@ async function main() {
}
let db: Database = configDb;

// Extract database name and server URL for display
const dbName: string = (configDb as unknown as { _getDatabaseName: string })
._getDatabaseName;
const baseUrl: string | undefined = (
configDb as unknown as { context?: { _getBaseUrl?: () => string } }
).context?._getBaseUrl?.();
// Extract database name and server URL for display.
// Try the public getter first (_getDatabaseName), fall back to the private field (databaseName).
const dbObj = configDb as unknown as {
_getDatabaseName?: string;
databaseName?: string;
context?: { _getBaseUrl?: () => string; _fetchClientOptions?: unknown };
};
const dbName: string = dbObj._getDatabaseName ?? dbObj.databaseName ?? "";
const baseUrl: string | undefined = dbObj.context?._getBaseUrl?.();
const serverUrl = baseUrl ? new URL(baseUrl).origin : undefined;

// If CLI credential overrides are provided, construct a new connection
Expand All @@ -89,18 +92,26 @@ async function main() {
process.exit(1);
}

const fetchClientOptions = dbObj.context?._fetchClientOptions as FFetchOptions | undefined;
const connection = new FMServerConnection({
serverUrl: serverUrl as string,
auth: {
username: options.username,
password: options.password,
},
fetchClientOptions,
});

db = connection.database(dbName);
}

const migrationPlan = await planMigration(db, betterAuthSchema);
let migrationPlan: Awaited<ReturnType<typeof planMigration>>;
try {
migrationPlan = await planMigration(db, betterAuthSchema);
} catch (err) {
logger.error(`Failed to read database schema: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}

if (migrationPlan.length === 0) {
logger.info("No changes to apply. Database is up to date.");
Expand Down
98 changes: 46 additions & 52 deletions packages/better-auth/src/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,9 @@ function normalizeBetterAuthFieldType(fieldType: unknown): string {
return String(fieldType);
}

export async function getMetadata(db: Database): Promise<Metadata | null> {
try {
const metadata = await db.getMetadata({ format: "json" });
return metadata;
} catch (err) {
console.error(chalk.red("Failed to get metadata:"), formatError(err));
return null;
}
export async function getMetadata(db: Database): Promise<Metadata> {
const metadata = await db.getMetadata({ format: "json" });
return metadata;
}

/** Map a better-auth field type string to an fmodata Field type */
Expand All @@ -42,52 +37,47 @@ export async function planMigration(db: Database, betterAuthSchema: BetterAuthSc

// Build a map from entity set name to entity type key
const entitySetToType: Record<string, string> = {};
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value.$Kind === "EntitySet" && value.$Type) {
// $Type is like 'betterauth_test.fmp12.proofkit_user_'
const typeKey = value.$Type.split(".").pop(); // e.g., 'proofkit_user_'
entitySetToType[key] = typeKey || key;
}
for (const [key, value] of Object.entries(metadata)) {
if (value.$Kind === "EntitySet" && value.$Type) {
// $Type is like 'betterauth_test.fmp12.proofkit_user_'
const typeKey = value.$Type.split(".").pop(); // e.g., 'proofkit_user_'
entitySetToType[key] = typeKey || key;
}
}

const existingTables = metadata
? Object.entries(entitySetToType).reduce(
(acc, [entitySetName, entityTypeKey]) => {
const entityType = metadata[entityTypeKey];
if (!entityType) {
return acc;
const existingTables = Object.entries(entitySetToType).reduce(
(acc, [entitySetName, entityTypeKey]) => {
const entityType = metadata[entityTypeKey];
if (!entityType) {
return acc;
}
const fields = Object.entries(entityType)
.filter(
([_fieldKey, fieldValue]) => typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue,
)
.map(([fieldKey, fieldValue]) => {
let type = "string";
if (fieldValue.$Type === "Edm.String") {
type = "string";
} else if (fieldValue.$Type === "Edm.DateTimeOffset") {
type = "timestamp";
} else if (
fieldValue.$Type === "Edm.Decimal" ||
fieldValue.$Type === "Edm.Int32" ||
fieldValue.$Type === "Edm.Int64"
) {
type = "numeric";
}
const fields = Object.entries(entityType)
.filter(
([_fieldKey, fieldValue]) =>
typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue,
)
.map(([fieldKey, fieldValue]) => {
let type = "string";
if (fieldValue.$Type === "Edm.String") {
type = "string";
} else if (fieldValue.$Type === "Edm.DateTimeOffset") {
type = "timestamp";
} else if (
fieldValue.$Type === "Edm.Decimal" ||
fieldValue.$Type === "Edm.Int32" ||
fieldValue.$Type === "Edm.Int64"
) {
type = "numeric";
}
return {
name: fieldKey,
type,
};
});
acc[entitySetName] = fields;
return acc;
},
{} as Record<string, { name: string; type: string }[]>,
)
: {};
return {
name: fieldKey,
type,
};
});
acc[entitySetName] = fields;
return acc;
},
{} as Record<string, { name: string; type: string }[]>,
);

const baTables = Object.entries(betterAuthSchema)
.sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0))
Expand Down Expand Up @@ -241,8 +231,12 @@ export function prettyPrintMigrationPlan(
console.log(chalk.bold.green("Migration plan:"));
if (target?.serverUrl || target?.fileName) {
const parts: string[] = [];
if (target.fileName) parts.push(chalk.cyan(target.fileName));
if (target.serverUrl) parts.push(chalk.gray(target.serverUrl));
if (target.fileName) {
parts.push(chalk.cyan(target.fileName));
}
if (target.serverUrl) {
parts.push(chalk.gray(target.serverUrl));
}
console.log(` Target: ${parts.join(" @ ")}`);
}
for (const step of migrationPlan) {
Expand Down
3 changes: 3 additions & 0 deletions packages/fmodata/src/client/filemaker-odata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ export class FMServerConnection implements ExecutionContext {
private useEntityIds = false;
private includeSpecialColumns = false;
private readonly logger: InternalLogger;
/** @internal Stored so credential-override flows can inherit non-auth config. */
readonly _fetchClientOptions: FFetchOptions | undefined;
constructor(config: {
serverUrl: string;
auth: Auth;
fetchClientOptions?: FFetchOptions;
logger?: Logger;
}) {
this.logger = createLogger(config.logger);
this._fetchClientOptions = config.fetchClientOptions;
this.fetchClient = createClient({
retries: 0,
...config.fetchClientOptions,
Expand Down
Loading