@proofkit/better-auth 0.3.1-beta.1 → 0.4.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node --no-warnings
2
2
  import { Command } from "@commander-js/extra-typings";
3
+ import type { Database, FFetchOptions } from "@proofkit/fmodata";
4
+ import { FMServerConnection } from "@proofkit/fmodata";
3
5
  import { logger } from "better-auth";
4
- import { getAdapter, getSchema } from "better-auth/db";
6
+ import { getSchema } from "better-auth/db";
5
7
  import chalk from "chalk";
6
8
  import fs from "fs-extra";
7
9
  import prompts from "prompts";
8
- import type { FileMakerAdapterConfig } from "../adapter";
9
10
  import { getConfig } from "../better-auth-cli/utils/get-config";
10
11
  import { executeMigration, planMigration, prettyPrintMigrationPlan } from "../migrate";
11
- import { createRawFetch } from "../odata";
12
12
  import "dotenv/config";
13
13
 
14
14
  async function main() {
@@ -40,33 +40,87 @@ async function main() {
40
40
  return;
41
41
  }
42
42
 
43
- const adapter = await getAdapter(config).catch((e) => {
44
- logger.error(e.message);
43
+ // Resolve adapter directly (getAdapter removed in Better Auth 1.5)
44
+ const databaseFactory = config.database;
45
+ if (!databaseFactory || typeof databaseFactory !== "function") {
46
+ logger.error("No database adapter found in auth config.");
45
47
  process.exit(1);
46
- });
48
+ }
49
+ let adapter: { id?: string; database?: unknown };
50
+ try {
51
+ adapter = (databaseFactory as (opts: unknown) => { id?: string; database?: unknown })(config);
52
+ } catch (e) {
53
+ logger.error(e instanceof Error ? e.message : String(e));
54
+ process.exit(1);
55
+ }
47
56
 
48
- if (adapter.id !== "filemaker") {
57
+ if (adapter?.id !== "filemaker") {
49
58
  logger.error("This generator is only compatible with the FileMaker adapter.");
50
59
  return;
51
60
  }
52
61
 
53
62
  const betterAuthSchema = getSchema(config);
54
63
 
55
- const adapterConfig = (adapter as unknown as { filemakerConfig: FileMakerAdapterConfig }).filemakerConfig;
56
- const { fetch } = createRawFetch({
57
- ...adapterConfig.odata,
58
- auth:
59
- // If the username and password are provided in the CLI, use them to authenticate instead of what's in the config file.
60
- options.username && options.password
61
- ? {
62
- username: options.username,
63
- password: options.password,
64
- }
65
- : adapterConfig.odata.auth,
66
- logging: "verbose", // Enable logging for CLI operations
67
- });
64
+ // Extract Database from the adapter factory or resolved adapter.
65
+ // config.database is the FileMakerAdapter factory function (has .database set on it).
66
+ // adapter is the resolved adapter after getAdapter() calls the factory (also has .database).
67
+ // Try both: adapter first (post-call), then config.database (pre-call / factory function).
68
+ const configDb =
69
+ (adapter as unknown as { database?: Database }).database ??
70
+ (config.database as unknown as { database?: Database } | undefined)?.database;
71
+ if (!configDb || typeof configDb !== "object" || !("schema" in configDb)) {
72
+ logger.error(
73
+ "Could not extract Database instance from adapter. Ensure your auth.ts uses FileMakerAdapter with an fmodata Database.",
74
+ );
75
+ process.exit(1);
76
+ }
77
+ let db: Database = configDb;
78
+
79
+ // Extract database name and server URL for display.
80
+ // Try the public getter first (_getDatabaseName), fall back to the private field (databaseName).
81
+ const dbObj = configDb as unknown as {
82
+ _getDatabaseName?: string;
83
+ databaseName?: string;
84
+ context?: { _getBaseUrl?: () => string; _fetchClientOptions?: unknown };
85
+ };
86
+ const dbName: string = dbObj._getDatabaseName ?? dbObj.databaseName ?? "";
87
+ const baseUrl: string | undefined = dbObj.context?._getBaseUrl?.();
88
+ const serverUrl = baseUrl ? new URL(baseUrl).origin : undefined;
89
+
90
+ // If CLI credential overrides are provided, construct a new connection
91
+ if (options.username && options.password) {
92
+ if (!dbName) {
93
+ logger.error("Could not determine database filename from adapter config.");
94
+ process.exit(1);
95
+ }
68
96
 
69
- const migrationPlan = await planMigration(fetch, betterAuthSchema, adapterConfig.odata.database);
97
+ if (!baseUrl) {
98
+ logger.error(
99
+ "Could not determine server URL from adapter config. Ensure your auth.ts uses FMServerConnection.",
100
+ );
101
+ process.exit(1);
102
+ }
103
+
104
+ const fetchClientOptions = dbObj.context?._fetchClientOptions as FFetchOptions | undefined;
105
+ const connection = new FMServerConnection({
106
+ serverUrl: serverUrl as string,
107
+ auth: {
108
+ username: options.username,
109
+ password: options.password,
110
+ },
111
+ fetchClientOptions,
112
+ });
113
+
114
+ db = connection.database(dbName);
115
+ }
116
+
117
+ let migrationPlan: Awaited<ReturnType<typeof planMigration>>;
118
+ try {
119
+ migrationPlan = await planMigration(db, betterAuthSchema);
120
+ } catch (err) {
121
+ logger.error(`Failed to read database schema: ${err instanceof Error ? err.message : err}`);
122
+ process.exit(1);
123
+ }
70
124
 
71
125
  if (migrationPlan.length === 0) {
72
126
  logger.info("No changes to apply. Database is up to date.");
@@ -74,7 +128,7 @@ async function main() {
74
128
  }
75
129
 
76
130
  if (!options.yes) {
77
- prettyPrintMigrationPlan(migrationPlan);
131
+ prettyPrintMigrationPlan(migrationPlan, { serverUrl, fileName: dbName });
78
132
 
79
133
  if (migrationPlan.length > 0) {
80
134
  console.log(chalk.gray("💡 Tip: You can use the --yes flag to skip this confirmation."));
@@ -91,12 +145,18 @@ async function main() {
91
145
  }
92
146
  }
93
147
 
94
- await executeMigration(fetch, migrationPlan);
95
-
96
- logger.info("Migration applied successfully.");
148
+ try {
149
+ await executeMigration(db, migrationPlan);
150
+ logger.info("Migration applied successfully.");
151
+ } catch {
152
+ process.exit(1);
153
+ }
97
154
  });
98
155
  await program.parseAsync(process.argv);
99
156
  process.exit(0);
100
157
  }
101
158
 
102
- main().catch(console.error);
159
+ main().catch((err) => {
160
+ logger.error(err.message ?? err);
161
+ process.exit(1);
162
+ });
package/src/migrate.ts CHANGED
@@ -1,8 +1,7 @@
1
+ import type { Database, Field, Metadata } from "@proofkit/fmodata";
2
+ import { isFMODataError, isODataError } from "@proofkit/fmodata";
1
3
  import type { DBFieldAttribute } from "better-auth/db";
2
4
  import chalk from "chalk";
3
- import type { Metadata } from "fm-odata-client";
4
- import z from "zod/v4";
5
- import type { createRawFetch } from "./odata";
6
5
 
7
6
  /** Schema type returned by better-auth's getSchema function */
8
7
  type BetterAuthSchema = Record<string, { fields: Record<string, DBFieldAttribute>; order: number }>;
@@ -17,112 +16,88 @@ function normalizeBetterAuthFieldType(fieldType: unknown): string {
17
16
  return String(fieldType);
18
17
  }
19
18
 
20
- export async function getMetadata(fetch: ReturnType<typeof createRawFetch>["fetch"], databaseName: string) {
21
- console.log("getting metadata...");
22
- const result = await fetch("/$metadata", {
23
- method: "GET",
24
- headers: { accept: "application/json" },
25
- output: z
26
- .looseObject({
27
- $Version: z.string(),
28
- "@ServerVersion": z.string(),
29
- })
30
- .or(z.null())
31
- .catch(null),
32
- });
19
+ export async function getMetadata(db: Database): Promise<Metadata> {
20
+ const metadata = await db.getMetadata({ format: "json" });
21
+ return metadata;
22
+ }
33
23
 
34
- if (result.error) {
35
- console.error("Failed to get metadata:", result.error);
36
- return null;
24
+ /** Map a better-auth field type string to an fmodata Field type */
25
+ function mapFieldType(t: string): "string" | "numeric" | "timestamp" {
26
+ if (t.includes("boolean") || t.includes("number")) {
27
+ return "numeric";
37
28
  }
38
-
39
- return (result.data?.[databaseName] ?? null) as Metadata | null;
29
+ if (t.includes("date")) {
30
+ return "timestamp";
31
+ }
32
+ return "string";
40
33
  }
41
34
 
42
- export async function planMigration(
43
- fetch: ReturnType<typeof createRawFetch>["fetch"],
44
- betterAuthSchema: BetterAuthSchema,
45
- databaseName: string,
46
- ): Promise<MigrationPlan> {
47
- const metadata = await getMetadata(fetch, databaseName);
35
+ export async function planMigration(db: Database, betterAuthSchema: BetterAuthSchema): Promise<MigrationPlan> {
36
+ const metadata = await getMetadata(db);
48
37
 
49
38
  // Build a map from entity set name to entity type key
50
39
  const entitySetToType: Record<string, string> = {};
51
- if (metadata) {
52
- for (const [key, value] of Object.entries(metadata)) {
53
- if (value.$Kind === "EntitySet" && value.$Type) {
54
- // $Type is like 'betterauth_test.fmp12.proofkit_user_'
55
- const typeKey = value.$Type.split(".").pop(); // e.g., 'proofkit_user_'
56
- entitySetToType[key] = typeKey || key;
57
- }
40
+ for (const [key, value] of Object.entries(metadata)) {
41
+ if (value.$Kind === "EntitySet" && value.$Type) {
42
+ // $Type is like 'betterauth_test.fmp12.proofkit_user_'
43
+ const typeKey = value.$Type.split(".").pop(); // e.g., 'proofkit_user_'
44
+ entitySetToType[key] = typeKey || key;
58
45
  }
59
46
  }
60
47
 
61
- const existingTables = metadata
62
- ? Object.entries(entitySetToType).reduce(
63
- (acc, [entitySetName, entityTypeKey]) => {
64
- const entityType = metadata[entityTypeKey];
65
- if (!entityType) {
66
- return acc;
48
+ const existingTables = Object.entries(entitySetToType).reduce(
49
+ (acc, [entitySetName, entityTypeKey]) => {
50
+ const entityType = metadata[entityTypeKey];
51
+ if (!entityType) {
52
+ return acc;
53
+ }
54
+ const fields = Object.entries(entityType)
55
+ .filter(
56
+ ([_fieldKey, fieldValue]) => typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue,
57
+ )
58
+ .map(([fieldKey, fieldValue]) => {
59
+ let type = "string";
60
+ if (fieldValue.$Type === "Edm.String") {
61
+ type = "string";
62
+ } else if (fieldValue.$Type === "Edm.DateTimeOffset") {
63
+ type = "timestamp";
64
+ } else if (
65
+ fieldValue.$Type === "Edm.Decimal" ||
66
+ fieldValue.$Type === "Edm.Int32" ||
67
+ fieldValue.$Type === "Edm.Int64"
68
+ ) {
69
+ type = "numeric";
67
70
  }
68
- const fields = Object.entries(entityType)
69
- .filter(
70
- ([_fieldKey, fieldValue]) =>
71
- typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue,
72
- )
73
- .map(([fieldKey, fieldValue]) => {
74
- let type = "varchar";
75
- if (fieldValue.$Type === "Edm.String") {
76
- type = "varchar";
77
- } else if (fieldValue.$Type === "Edm.DateTimeOffset") {
78
- type = "timestamp";
79
- } else if (
80
- fieldValue.$Type === "Edm.Decimal" ||
81
- fieldValue.$Type === "Edm.Int32" ||
82
- fieldValue.$Type === "Edm.Int64"
83
- ) {
84
- type = "numeric";
85
- }
86
- return {
87
- name: fieldKey,
88
- type,
89
- };
90
- });
91
- acc[entitySetName] = fields;
92
- return acc;
93
- },
94
- {} as Record<string, { name: string; type: string }[]>,
95
- )
96
- : {};
71
+ return {
72
+ name: fieldKey,
73
+ type,
74
+ };
75
+ });
76
+ acc[entitySetName] = fields;
77
+ return acc;
78
+ },
79
+ {} as Record<string, { name: string; type: string }[]>,
80
+ );
97
81
 
98
82
  const baTables = Object.entries(betterAuthSchema)
99
83
  .sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0))
100
84
  .map(([key, value]) => ({
101
85
  ...value,
102
- modelName: key, // Use the key as modelName since getSchema uses table names as keys
86
+ modelName: key,
103
87
  }));
104
88
 
105
89
  const migrationPlan: MigrationPlan = [];
106
90
 
107
91
  for (const baTable of baTables) {
108
92
  const fields: FmField[] = Object.entries(baTable.fields).map(([key, field]) => {
109
- // Better Auth's FieldType can be a string literal union or arrays.
110
- // Normalize it to a string so our FM mapping logic remains stable.
111
- // Use .includes() for all checks to handle array types like ["boolean", "null"] → "boolean|null"
112
93
  const t = normalizeBetterAuthFieldType(field.type);
113
- let type: "varchar" | "numeric" | "timestamp" = "varchar";
114
- if (t.includes("boolean") || t.includes("number")) {
115
- type = "numeric";
116
- } else if (t.includes("date")) {
117
- type = "timestamp";
118
- }
94
+ const type = mapFieldType(t);
119
95
  return {
120
96
  name: field.fieldName ?? key,
121
97
  type,
122
98
  };
123
99
  });
124
100
 
125
- // get existing table or create it
126
101
  const tableExists = baTable.modelName in existingTables;
127
102
 
128
103
  if (tableExists) {
@@ -134,7 +109,6 @@ export async function planMigration(
134
109
  },
135
110
  {} as Record<string, string>,
136
111
  );
137
- // Warn about type mismatches (optional, not in plan)
138
112
  for (const field of fields) {
139
113
  if (existingFields.includes(field.name) && existingFieldMap[field.name] !== field.type) {
140
114
  console.warn(
@@ -157,7 +131,7 @@ export async function planMigration(
157
131
  fields: [
158
132
  {
159
133
  name: "id",
160
- type: "varchar",
134
+ type: "string",
161
135
  primary: true,
162
136
  unique: true,
163
137
  },
@@ -170,106 +144,101 @@ export async function planMigration(
170
144
  return migrationPlan;
171
145
  }
172
146
 
173
- export async function executeMigration(
174
- fetch: ReturnType<typeof createRawFetch>["fetch"],
175
- migrationPlan: MigrationPlan,
176
- ) {
147
+ export async function executeMigration(db: Database, migrationPlan: MigrationPlan) {
177
148
  for (const step of migrationPlan) {
149
+ // Convert plan fields to fmodata Field type
150
+ const fmodataFields: Field[] = step.fields.map((f) => ({
151
+ name: f.name,
152
+ type: f.type,
153
+ ...(f.primary ? { primary: true } : {}),
154
+ ...(f.unique ? { unique: true } : {}),
155
+ }));
156
+
178
157
  if (step.operation === "create") {
179
158
  console.log("Creating table:", step.tableName);
180
- const result = await fetch("/FileMaker_Tables", {
181
- method: "POST",
182
- body: {
183
- tableName: step.tableName,
184
- fields: step.fields,
185
- },
186
- });
187
-
188
- if (result.error) {
189
- console.error(`Failed to create table ${step.tableName}:`, result.error);
190
- throw new Error(`Migration failed: ${result.error}`);
159
+ try {
160
+ await db.schema.createTable(step.tableName, fmodataFields);
161
+ } catch (error) {
162
+ throw migrationError("create", step.tableName, error);
191
163
  }
192
164
  } else if (step.operation === "update") {
193
165
  console.log("Adding fields to table:", step.tableName);
194
- const result = await fetch(`/FileMaker_Tables/${step.tableName}`, {
195
- method: "PATCH",
196
- body: { fields: step.fields },
197
- });
198
-
199
- if (result.error) {
200
- console.error(`Failed to update table ${step.tableName}:`, result.error);
201
- throw new Error(`Migration failed: ${result.error}`);
166
+ try {
167
+ await db.schema.addFields(step.tableName, fmodataFields);
168
+ } catch (error) {
169
+ throw migrationError("update", step.tableName, error);
202
170
  }
203
171
  }
204
172
  }
205
173
  }
206
174
 
207
- const genericFieldSchema = z.object({
208
- name: z.string(),
209
- nullable: z.boolean().optional(),
210
- primary: z.boolean().optional(),
211
- unique: z.boolean().optional(),
212
- global: z.boolean().optional(),
213
- repetitions: z.number().optional(),
214
- });
215
-
216
- const stringFieldSchema = genericFieldSchema.extend({
217
- type: z.literal("varchar"),
218
- maxLength: z.number().optional(),
219
- default: z.enum(["USER", "USERNAME", "CURRENT_USER"]).optional(),
220
- });
221
-
222
- const numericFieldSchema = genericFieldSchema.extend({
223
- type: z.literal("numeric"),
224
- });
225
-
226
- const dateFieldSchema = genericFieldSchema.extend({
227
- type: z.literal("date"),
228
- default: z.enum(["CURRENT_DATE", "CURDATE"]).optional(),
229
- });
230
-
231
- const timeFieldSchema = genericFieldSchema.extend({
232
- type: z.literal("time"),
233
- default: z.enum(["CURRENT_TIME", "CURTIME"]).optional(),
234
- });
235
-
236
- const timestampFieldSchema = genericFieldSchema.extend({
237
- type: z.literal("timestamp"),
238
- default: z.enum(["CURRENT_TIMESTAMP", "CURTIMESTAMP"]).optional(),
239
- });
175
+ interface FmField {
176
+ name: string;
177
+ type: "string" | "numeric" | "timestamp";
178
+ primary?: boolean;
179
+ unique?: boolean;
180
+ }
240
181
 
241
- const containerFieldSchema = genericFieldSchema.extend({
242
- type: z.literal("container"),
243
- externalSecurePath: z.string().optional(),
244
- });
182
+ const migrationStepTypes = ["create", "update"] as const;
183
+ interface MigrationStep {
184
+ tableName: string;
185
+ operation: (typeof migrationStepTypes)[number];
186
+ fields: FmField[];
187
+ }
245
188
 
246
- const fieldSchema = z.discriminatedUnion("type", [
247
- stringFieldSchema,
248
- numericFieldSchema,
249
- dateFieldSchema,
250
- timeFieldSchema,
251
- timestampFieldSchema,
252
- containerFieldSchema,
253
- ]);
189
+ export type MigrationPlan = MigrationStep[];
254
190
 
255
- type FmField = z.infer<typeof fieldSchema>;
191
+ function formatError(error: unknown): string {
192
+ if (isODataError(error)) {
193
+ const code = error.code ? ` (${error.code})` : "";
194
+ return `${error.message}${code}`;
195
+ }
196
+ if (isFMODataError(error)) {
197
+ return error.message;
198
+ }
199
+ if (error instanceof Error) {
200
+ return error.message;
201
+ }
202
+ return String(error);
203
+ }
256
204
 
257
- const migrationPlanSchema = z
258
- .object({
259
- tableName: z.string(),
260
- operation: z.enum(["create", "update"]),
261
- fields: z.array(fieldSchema),
262
- })
263
- .array();
205
+ function migrationError(operation: string, tableName: string, error: unknown): Error {
206
+ const action = operation === "create" ? "create table" : "update table";
207
+ const base = `Failed to ${action} "${tableName}"`;
264
208
 
265
- export type MigrationPlan = z.infer<typeof migrationPlanSchema>;
209
+ if (isODataError(error) && error.code === "207") {
210
+ console.error(
211
+ chalk.red(`\n${base}: Cannot modify schema.`),
212
+ chalk.yellow("\nThe account used does not have schema modification privileges."),
213
+ chalk.gray(
214
+ "\nUse --username and --password to provide Full Access credentials, or grant schema modification privileges to the current account.",
215
+ ),
216
+ );
217
+ } else {
218
+ console.error(chalk.red(`\n${base}:`), formatError(error));
219
+ }
220
+ return new Error(`Migration failed: ${formatError(error)}`);
221
+ }
266
222
 
267
- export function prettyPrintMigrationPlan(migrationPlan: MigrationPlan) {
223
+ export function prettyPrintMigrationPlan(
224
+ migrationPlan: MigrationPlan,
225
+ target?: { serverUrl?: string; fileName?: string },
226
+ ) {
268
227
  if (!migrationPlan.length) {
269
228
  console.log("No changes to apply. Database is up to date.");
270
229
  return;
271
230
  }
272
231
  console.log(chalk.bold.green("Migration plan:"));
232
+ if (target?.serverUrl || target?.fileName) {
233
+ const parts: string[] = [];
234
+ if (target.fileName) {
235
+ parts.push(chalk.cyan(target.fileName));
236
+ }
237
+ if (target.serverUrl) {
238
+ parts.push(chalk.gray(target.serverUrl));
239
+ }
240
+ console.log(` Target: ${parts.join(" @ ")}`);
241
+ }
273
242
  for (const step of migrationPlan) {
274
243
  const emoji = step.operation === "create" ? "✅" : "✏️";
275
244
  console.log(
@@ -1,29 +0,0 @@
1
- import { Result } from 'neverthrow';
2
- import { z } from 'zod/v4';
3
- interface BasicAuthCredentials {
4
- username: string;
5
- password: string;
6
- }
7
- interface OttoAPIKeyAuth {
8
- apiKey: string;
9
- }
10
- type ODataAuth = BasicAuthCredentials | OttoAPIKeyAuth;
11
- export interface FmOdataConfig {
12
- serverUrl: string;
13
- auth: ODataAuth;
14
- database: string;
15
- logging?: true | "verbose" | "none";
16
- }
17
- export declare function validateUrl(input: string): Result<URL, unknown>;
18
- export declare function createRawFetch(args: FmOdataConfig): {
19
- baseURL: string;
20
- fetch: <TOutput = any>(input: string | URL | Request, options?: Omit<RequestInit, "body"> & {
21
- body?: any;
22
- output?: z.ZodSchema<TOutput>;
23
- }) => Promise<{
24
- data?: TOutput;
25
- error?: string;
26
- response?: Response;
27
- }>;
28
- };
29
- export {};