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

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/adapter.ts CHANGED
@@ -1,19 +1,7 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: library code */
2
+ import type { Database } from "@proofkit/fmodata";
2
3
  import { logger } from "better-auth";
3
4
  import { type CleanedWhere, createAdapter, type DBAdapterDebugLogOption } from "better-auth/adapters";
4
- import buildQuery from "odata-query";
5
- import { prettifyError, z } from "zod/v4";
6
- import { createRawFetch, type FmOdataConfig } from "./odata";
7
-
8
- const configSchema = z.object({
9
- debugLogs: z.unknown().optional(),
10
- usePlural: z.boolean().optional(),
11
- odata: z.object({
12
- serverUrl: z.url(),
13
- auth: z.union([z.object({ username: z.string(), password: z.string() }), z.object({ apiKey: z.string() })]),
14
- database: z.string().endsWith(".fmp12"),
15
- }),
16
- });
17
5
 
18
6
  export interface FileMakerAdapterConfig {
19
7
  /**
@@ -24,27 +12,12 @@ export interface FileMakerAdapterConfig {
24
12
  * If the table names in the schema are plural.
25
13
  */
26
14
  usePlural?: boolean;
27
-
28
15
  /**
29
- * Connection details for the FileMaker server.
16
+ * The fmodata Database instance to use for all OData requests.
30
17
  */
31
- odata: FmOdataConfig;
32
- }
33
-
34
- export interface AdapterOptions {
35
- config: FileMakerAdapterConfig;
18
+ database: Database;
36
19
  }
37
20
 
38
- const defaultConfig: Required<FileMakerAdapterConfig> = {
39
- debugLogs: false,
40
- usePlural: false,
41
- odata: {
42
- serverUrl: "",
43
- auth: { username: "", password: "" },
44
- database: "",
45
- },
46
- };
47
-
48
21
  // Regex patterns for field validation and ISO date detection
49
22
  const FIELD_SPECIAL_CHARS_REGEX = /[\s_]/;
50
23
  const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
@@ -155,41 +128,59 @@ export function parseWhere(where?: CleanedWhere[]): string {
155
128
  return clauses.join(" ");
156
129
  }
157
130
 
158
- export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig) => {
159
- const parsed = configSchema.loose().safeParse(_config);
131
+ /**
132
+ * Build an OData query string from parameters.
133
+ */
134
+ function buildQueryString(params: {
135
+ top?: number;
136
+ skip?: number;
137
+ filter?: string;
138
+ orderBy?: string;
139
+ select?: string[];
140
+ }): string {
141
+ const parts: string[] = [];
142
+ if (params.top !== undefined) {
143
+ parts.push(`$top=${params.top}`);
144
+ }
145
+ if (params.skip !== undefined) {
146
+ parts.push(`$skip=${params.skip}`);
147
+ }
148
+ if (params.filter) {
149
+ parts.push(`$filter=${encodeURIComponent(params.filter)}`);
150
+ }
151
+ if (params.orderBy) {
152
+ parts.push(`$orderby=${encodeURIComponent(params.orderBy)}`);
153
+ }
154
+ if (params.select?.length) {
155
+ parts.push(`$select=${params.select.map(encodeURIComponent).join(",")}`);
156
+ }
157
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
158
+ }
160
159
 
161
- if (!parsed.success) {
162
- throw new Error(`Invalid configuration: ${prettifyError(parsed.error)}`);
160
+ export const FileMakerAdapter = (config: FileMakerAdapterConfig) => {
161
+ if (!config.database || typeof config.database !== "object") {
162
+ throw new Error("FileMakerAdapter requires a `database` (fmodata Database instance).");
163
163
  }
164
- const config = parsed.data;
165
164
 
166
- const { fetch } = createRawFetch({
167
- ...config.odata,
168
- logging: config.debugLogs ? "verbose" : "none",
169
- });
165
+ const db = config.database;
170
166
 
171
167
  const adapterFactory = createAdapter({
172
168
  config: {
173
169
  adapterId: "filemaker",
174
170
  adapterName: "FileMaker",
175
- usePlural: config.usePlural ?? false, // Whether the table names in the schema are plural.
176
- debugLogs: config.debugLogs ?? false, // Whether to enable debug logs.
177
- supportsJSON: false, // Whether the database supports JSON. (Default: false)
178
- supportsDates: false, // Whether the database supports dates. (Default: true)
179
- supportsBooleans: false, // Whether the database supports booleans. (Default: true)
180
- supportsNumericIds: false, // Whether the database supports auto-incrementing numeric IDs. (Default: true)
171
+ usePlural: config.usePlural ?? false,
172
+ debugLogs: config.debugLogs ?? false,
173
+ supportsJSON: false,
174
+ supportsDates: false,
175
+ supportsBooleans: false,
176
+ supportsNumericIds: false,
181
177
  },
182
178
  adapter: () => {
183
179
  return {
184
180
  create: async ({ data, model }) => {
185
- if (model === "session") {
186
- console.log("session", data);
187
- }
188
-
189
- const result = await fetch(`/${model}`, {
181
+ const result = await db._makeRequest<Record<string, any>>(`/${model}`, {
190
182
  method: "POST",
191
- body: data,
192
- output: z.looseObject({ id: z.string() }),
183
+ body: JSON.stringify(data),
193
184
  });
194
185
 
195
186
  if (result.error) {
@@ -202,15 +193,12 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
202
193
  const filter = parseWhere(where);
203
194
  logger.debug("$filter", filter);
204
195
 
205
- const query = buildQuery({
196
+ const query = buildQueryString({
206
197
  filter: filter.length > 0 ? filter : undefined,
207
198
  });
208
199
 
209
- const result = await fetch(`/${model}/$count${query}`, {
210
- method: "GET",
211
- output: z.object({ value: z.number() }),
212
- });
213
- if (!result.data) {
200
+ const result = await db._makeRequest<{ value: number }>(`/${model}/$count${query}`);
201
+ if (result.error) {
214
202
  throw new Error("Failed to count records");
215
203
  }
216
204
  return (result.data?.value as any) ?? 0;
@@ -219,15 +207,12 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
219
207
  const filter = parseWhere(where);
220
208
  logger.debug("$filter", filter);
221
209
 
222
- const query = buildQuery({
210
+ const query = buildQueryString({
223
211
  top: 1,
224
212
  filter: filter.length > 0 ? filter : undefined,
225
213
  });
226
214
 
227
- const result = await fetch(`/${model}${query}`, {
228
- method: "GET",
229
- output: z.object({ value: z.array(z.any()) }),
230
- });
215
+ const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
231
216
  if (result.error) {
232
217
  throw new Error("Failed to find record");
233
218
  }
@@ -237,7 +222,7 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
237
222
  const filter = parseWhere(where);
238
223
  logger.debug("FIND MANY", { where, filter });
239
224
 
240
- const query = buildQuery({
225
+ const query = buildQueryString({
241
226
  top: limit,
242
227
  skip: offset,
243
228
  orderBy: sortBy ? `${sortBy.field} ${sortBy.direction ?? "asc"}` : undefined,
@@ -245,10 +230,7 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
245
230
  });
246
231
  logger.debug("QUERY", query);
247
232
 
248
- const result = await fetch(`/${model}${query}`, {
249
- method: "GET",
250
- output: z.object({ value: z.array(z.any()) }),
251
- });
233
+ const result = await db._makeRequest<{ value: any[] }>(`/${model}${query}`);
252
234
  logger.debug("RESULT", result);
253
235
 
254
236
  if (result.error) {
@@ -259,54 +241,44 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
259
241
  },
260
242
  delete: async ({ model, where }) => {
261
243
  const filter = parseWhere(where);
262
- console.log("DELETE", { model, where, filter });
263
244
  logger.debug("$filter", filter);
264
245
 
265
246
  // Find a single id matching the filter
266
- const query = buildQuery({
247
+ const query = buildQueryString({
267
248
  top: 1,
268
249
  select: [`"id"`],
269
250
  filter: filter.length > 0 ? filter : undefined,
270
251
  });
271
252
 
272
- const toDelete = await fetch(`/${model}${query}`, {
273
- method: "GET",
274
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
275
- });
253
+ const toDelete = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
276
254
 
277
255
  const id = toDelete.data?.value?.[0]?.id;
278
256
  if (!id) {
279
- // Nothing to delete
280
257
  return;
281
258
  }
282
259
 
283
- const result = await fetch(`/${model}('${id}')`, {
260
+ const result = await db._makeRequest(`/${model}('${id}')`, {
284
261
  method: "DELETE",
285
262
  });
286
263
  if (result.error) {
287
- console.log("DELETE ERROR", result.error);
288
264
  throw new Error("Failed to delete record");
289
265
  }
290
266
  },
291
267
  deleteMany: async ({ model, where }) => {
292
268
  const filter = parseWhere(where);
293
- console.log("DELETE MANY", { model, where, filter });
294
269
 
295
270
  // Find all ids matching the filter
296
- const query = buildQuery({
271
+ const query = buildQueryString({
297
272
  select: [`"id"`],
298
273
  filter: filter.length > 0 ? filter : undefined,
299
274
  });
300
275
 
301
- const rows = await fetch(`/${model}${query}`, {
302
- method: "GET",
303
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
304
- });
276
+ const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
305
277
 
306
278
  const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
307
279
  let deleted = 0;
308
280
  for (const id of ids) {
309
- const res = await fetch(`/${model}('${id}')`, {
281
+ const res = await db._makeRequest(`/${model}('${id}')`, {
310
282
  method: "DELETE",
311
283
  });
312
284
  if (!res.error) {
@@ -319,16 +291,14 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
319
291
  const filter = parseWhere(where);
320
292
  logger.debug("UPDATE", { model, where, update });
321
293
  logger.debug("$filter", filter);
294
+
322
295
  // Find one id to update
323
- const query = buildQuery({
296
+ const query = buildQueryString({
324
297
  select: [`"id"`],
325
298
  filter: filter.length > 0 ? filter : undefined,
326
299
  });
327
300
 
328
- const existing = await fetch(`/${model}${query}`, {
329
- method: "GET",
330
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
331
- });
301
+ const existing = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
332
302
  logger.debug("EXISTING", existing.data);
333
303
 
334
304
  const id = existing.data?.value?.[0]?.id;
@@ -336,9 +306,9 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
336
306
  return null;
337
307
  }
338
308
 
339
- const patchRes = await fetch(`/${model}('${id}')`, {
309
+ const patchRes = await db._makeRequest(`/${model}('${id}')`, {
340
310
  method: "PATCH",
341
- body: update,
311
+ body: JSON.stringify(update),
342
312
  });
343
313
  logger.debug("PATCH RES", patchRes.data);
344
314
  if (patchRes.error) {
@@ -346,32 +316,27 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
346
316
  }
347
317
 
348
318
  // Read back the updated record
349
- const readBack = await fetch(`/${model}('${id}')`, {
350
- method: "GET",
351
- output: z.record(z.string(), z.unknown()),
352
- });
319
+ const readBack = await db._makeRequest<Record<string, unknown>>(`/${model}('${id}')`);
353
320
  logger.debug("READ BACK", readBack.data);
354
321
  return (readBack.data as any) ?? null;
355
322
  },
356
323
  updateMany: async ({ model, where, update }) => {
357
324
  const filter = parseWhere(where);
325
+
358
326
  // Find all ids matching the filter
359
- const query = buildQuery({
327
+ const query = buildQueryString({
360
328
  select: [`"id"`],
361
329
  filter: filter.length > 0 ? filter : undefined,
362
330
  });
363
331
 
364
- const rows = await fetch(`/${model}${query}`, {
365
- method: "GET",
366
- output: z.object({ value: z.array(z.object({ id: z.string() })) }),
367
- });
332
+ const rows = await db._makeRequest<{ value: { id: string }[] }>(`/${model}${query}`);
368
333
 
369
334
  const ids = rows.data?.value?.map((r: any) => r.id) ?? [];
370
335
  let updated = 0;
371
336
  for (const id of ids) {
372
- const res = await fetch(`/${model}('${id}')`, {
337
+ const res = await db._makeRequest(`/${model}('${id}')`, {
373
338
  method: "PATCH",
374
- body: update,
339
+ body: JSON.stringify(update),
375
340
  });
376
341
  if (!res.error) {
377
342
  updated++;
@@ -383,7 +348,15 @@ export const FileMakerAdapter = (_config: FileMakerAdapterConfig = defaultConfig
383
348
  },
384
349
  });
385
350
 
386
- // Expose the FileMaker config for CLI access
387
- (adapterFactory as any).filemakerConfig = config as FileMakerAdapterConfig;
388
- return adapterFactory;
351
+ // Expose the Database instance for CLI access.
352
+ // Set on both the factory function (for pre-getAdapter extraction)
353
+ // and the returned adapter (for post-getAdapter extraction).
354
+ const originalFactory = adapterFactory;
355
+ const wrappedFactory = ((options: unknown) => {
356
+ const adapter = (originalFactory as (opts: unknown) => Record<string, unknown>)(options);
357
+ adapter.database = db;
358
+ return adapter;
359
+ }) as typeof adapterFactory;
360
+ (wrappedFactory as unknown as { database: Database }).database = db;
361
+ return wrappedFactory;
389
362
  };
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
6
  import { getAdapter, 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() {
@@ -52,21 +52,66 @@ async function main() {
52
52
 
53
53
  const betterAuthSchema = getSchema(config);
54
54
 
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
- });
55
+ // Extract Database from the adapter factory or resolved adapter.
56
+ // config.database is the FileMakerAdapter factory function (has .database set on it).
57
+ // adapter is the resolved adapter after getAdapter() calls the factory (also has .database).
58
+ // Try both: adapter first (post-call), then config.database (pre-call / factory function).
59
+ const configDb =
60
+ (adapter as unknown as { database?: Database }).database ??
61
+ (config.database as unknown as { database?: Database } | undefined)?.database;
62
+ if (!configDb || typeof configDb !== "object" || !("schema" in configDb)) {
63
+ logger.error(
64
+ "Could not extract Database instance from adapter. Ensure your auth.ts uses FileMakerAdapter with an fmodata Database.",
65
+ );
66
+ process.exit(1);
67
+ }
68
+ let db: Database = configDb;
69
+
70
+ // Extract database name and server URL for display.
71
+ // Try the public getter first (_getDatabaseName), fall back to the private field (databaseName).
72
+ const dbObj = configDb as unknown as {
73
+ _getDatabaseName?: string;
74
+ databaseName?: string;
75
+ context?: { _getBaseUrl?: () => string; _fetchClientOptions?: unknown };
76
+ };
77
+ const dbName: string = dbObj._getDatabaseName ?? dbObj.databaseName ?? "";
78
+ const baseUrl: string | undefined = dbObj.context?._getBaseUrl?.();
79
+ const serverUrl = baseUrl ? new URL(baseUrl).origin : undefined;
80
+
81
+ // If CLI credential overrides are provided, construct a new connection
82
+ if (options.username && options.password) {
83
+ if (!dbName) {
84
+ logger.error("Could not determine database filename from adapter config.");
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!baseUrl) {
89
+ logger.error(
90
+ "Could not determine server URL from adapter config. Ensure your auth.ts uses FMServerConnection.",
91
+ );
92
+ process.exit(1);
93
+ }
68
94
 
69
- const migrationPlan = await planMigration(fetch, betterAuthSchema, adapterConfig.odata.database);
95
+ const fetchClientOptions = dbObj.context?._fetchClientOptions as FFetchOptions | undefined;
96
+ const connection = new FMServerConnection({
97
+ serverUrl: serverUrl as string,
98
+ auth: {
99
+ username: options.username,
100
+ password: options.password,
101
+ },
102
+ fetchClientOptions,
103
+ });
104
+
105
+ db = connection.database(dbName);
106
+ }
107
+
108
+ let migrationPlan: Awaited<ReturnType<typeof planMigration>>;
109
+ try {
110
+ migrationPlan = await planMigration(db, betterAuthSchema);
111
+ } catch (err) {
112
+ logger.error(`Failed to read database schema: ${err instanceof Error ? err.message : err}`);
113
+ process.exit(1);
114
+ }
70
115
 
71
116
  if (migrationPlan.length === 0) {
72
117
  logger.info("No changes to apply. Database is up to date.");
@@ -74,7 +119,7 @@ async function main() {
74
119
  }
75
120
 
76
121
  if (!options.yes) {
77
- prettyPrintMigrationPlan(migrationPlan);
122
+ prettyPrintMigrationPlan(migrationPlan, { serverUrl, fileName: dbName });
78
123
 
79
124
  if (migrationPlan.length > 0) {
80
125
  console.log(chalk.gray("💡 Tip: You can use the --yes flag to skip this confirmation."));
@@ -91,12 +136,18 @@ async function main() {
91
136
  }
92
137
  }
93
138
 
94
- await executeMigration(fetch, migrationPlan);
95
-
96
- logger.info("Migration applied successfully.");
139
+ try {
140
+ await executeMigration(db, migrationPlan);
141
+ logger.info("Migration applied successfully.");
142
+ } catch {
143
+ process.exit(1);
144
+ }
97
145
  });
98
146
  await program.parseAsync(process.argv);
99
147
  process.exit(0);
100
148
  }
101
149
 
102
- main().catch(console.error);
150
+ main().catch((err) => {
151
+ logger.error(err.message ?? err);
152
+ process.exit(1);
153
+ });