@proofkit/better-auth 0.3.0 → 0.3.1-beta.1

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/migrate.ts CHANGED
@@ -1,13 +1,23 @@
1
- import { type BetterAuthDbSchema } from "better-auth/db";
2
- import { type Metadata } from "fm-odata-client";
1
+ import type { DBFieldAttribute } from "better-auth/db";
3
2
  import chalk from "chalk";
3
+ import type { Metadata } from "fm-odata-client";
4
4
  import z from "zod/v4";
5
- import { createRawFetch } from "./odata";
5
+ import type { createRawFetch } from "./odata";
6
6
 
7
- export async function getMetadata(
8
- fetch: ReturnType<typeof createRawFetch>["fetch"],
9
- databaseName: string,
10
- ) {
7
+ /** Schema type returned by better-auth's getSchema function */
8
+ type BetterAuthSchema = Record<string, { fields: Record<string, DBFieldAttribute>; order: number }>;
9
+
10
+ function normalizeBetterAuthFieldType(fieldType: unknown): string {
11
+ if (typeof fieldType === "string") {
12
+ return fieldType;
13
+ }
14
+ if (Array.isArray(fieldType)) {
15
+ return fieldType.map(String).join("|");
16
+ }
17
+ return String(fieldType);
18
+ }
19
+
20
+ export async function getMetadata(fetch: ReturnType<typeof createRawFetch>["fetch"], databaseName: string) {
11
21
  console.log("getting metadata...");
12
22
  const result = await fetch("/$metadata", {
13
23
  method: "GET",
@@ -31,13 +41,13 @@ export async function getMetadata(
31
41
 
32
42
  export async function planMigration(
33
43
  fetch: ReturnType<typeof createRawFetch>["fetch"],
34
- betterAuthSchema: BetterAuthDbSchema,
44
+ betterAuthSchema: BetterAuthSchema,
35
45
  databaseName: string,
36
46
  ): Promise<MigrationPlan> {
37
47
  const metadata = await getMetadata(fetch, databaseName);
38
48
 
39
49
  // Build a map from entity set name to entity type key
40
- let entitySetToType: Record<string, string> = {};
50
+ const entitySetToType: Record<string, string> = {};
41
51
  if (metadata) {
42
52
  for (const [key, value] of Object.entries(metadata)) {
43
53
  if (value.$Kind === "EntitySet" && value.$Type) {
@@ -52,27 +62,32 @@ export async function planMigration(
52
62
  ? Object.entries(entitySetToType).reduce(
53
63
  (acc, [entitySetName, entityTypeKey]) => {
54
64
  const entityType = metadata[entityTypeKey];
55
- if (!entityType) return acc;
65
+ if (!entityType) {
66
+ return acc;
67
+ }
56
68
  const fields = Object.entries(entityType)
57
69
  .filter(
58
- ([fieldKey, fieldValue]) =>
59
- typeof fieldValue === "object" &&
60
- fieldValue !== null &&
61
- "$Type" in fieldValue,
70
+ ([_fieldKey, fieldValue]) =>
71
+ typeof fieldValue === "object" && fieldValue !== null && "$Type" in fieldValue,
62
72
  )
63
- .map(([fieldKey, fieldValue]) => ({
64
- name: fieldKey,
65
- type:
66
- fieldValue.$Type === "Edm.String"
67
- ? "varchar"
68
- : fieldValue.$Type === "Edm.DateTimeOffset"
69
- ? "timestamp"
70
- : fieldValue.$Type === "Edm.Decimal" ||
71
- fieldValue.$Type === "Edm.Int32" ||
72
- fieldValue.$Type === "Edm.Int64"
73
- ? "numeric"
74
- : "varchar",
75
- }));
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
+ });
76
91
  acc[entitySetName] = fields;
77
92
  return acc;
78
93
  },
@@ -84,48 +99,34 @@ export async function planMigration(
84
99
  .sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0))
85
100
  .map(([key, value]) => ({
86
101
  ...value,
87
- keyName: key,
102
+ modelName: key, // Use the key as modelName since getSchema uses table names as keys
88
103
  }));
89
104
 
90
105
  const migrationPlan: MigrationPlan = [];
91
106
 
92
107
  for (const baTable of baTables) {
93
- const fields: FmField[] = Object.entries(baTable.fields).map(
94
- ([key, field]) => ({
108
+ 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
+ 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
+ }
119
+ return {
95
120
  name: field.fieldName ?? key,
96
- type:
97
- field.type === "boolean" || field.type.includes("number")
98
- ? "numeric"
99
- : field.type === "date"
100
- ? "timestamp"
101
- : "varchar",
102
- }),
103
- );
121
+ type,
122
+ };
123
+ });
104
124
 
105
125
  // get existing table or create it
106
- const tableExists = Object.prototype.hasOwnProperty.call(
107
- existingTables,
108
- baTable.modelName,
109
- );
126
+ const tableExists = baTable.modelName in existingTables;
110
127
 
111
- if (!tableExists) {
112
- migrationPlan.push({
113
- tableName: baTable.modelName,
114
- operation: "create",
115
- fields: [
116
- {
117
- name: "id",
118
- type: "varchar",
119
- primary: true,
120
- unique: true,
121
- },
122
- ...fields,
123
- ],
124
- });
125
- } else {
126
- const existingFields = (existingTables[baTable.modelName] || []).map(
127
- (f) => f.name,
128
- );
128
+ if (tableExists) {
129
+ const existingFields = (existingTables[baTable.modelName] || []).map((f) => f.name);
129
130
  const existingFieldMap = (existingTables[baTable.modelName] || []).reduce(
130
131
  (acc, f) => {
131
132
  acc[f.name] = f.type;
@@ -134,19 +135,14 @@ export async function planMigration(
134
135
  {} as Record<string, string>,
135
136
  );
136
137
  // Warn about type mismatches (optional, not in plan)
137
- fields.forEach((field) => {
138
- if (
139
- existingFields.includes(field.name) &&
140
- existingFieldMap[field.name] !== field.type
141
- ) {
138
+ for (const field of fields) {
139
+ if (existingFields.includes(field.name) && existingFieldMap[field.name] !== field.type) {
142
140
  console.warn(
143
141
  `⚠️ WARNING: Field '${field.name}' in table '${baTable.modelName}' exists but has type '${existingFieldMap[field.name]}' (expected '${field.type}'). Change the field type in FileMaker to avoid potential errors.`,
144
142
  );
145
143
  }
146
- });
147
- const fieldsToAdd = fields.filter(
148
- (f) => !existingFields.includes(f.name),
149
- );
144
+ }
145
+ const fieldsToAdd = fields.filter((f) => !existingFields.includes(f.name));
150
146
  if (fieldsToAdd.length > 0) {
151
147
  migrationPlan.push({
152
148
  tableName: baTable.modelName,
@@ -154,6 +150,20 @@ export async function planMigration(
154
150
  fields: fieldsToAdd,
155
151
  });
156
152
  }
153
+ } else {
154
+ migrationPlan.push({
155
+ tableName: baTable.modelName,
156
+ operation: "create",
157
+ fields: [
158
+ {
159
+ name: "id",
160
+ type: "varchar",
161
+ primary: true,
162
+ unique: true,
163
+ },
164
+ ...fields,
165
+ ],
166
+ });
157
167
  }
158
168
  }
159
169
 
@@ -176,10 +186,7 @@ export async function executeMigration(
176
186
  });
177
187
 
178
188
  if (result.error) {
179
- console.error(
180
- `Failed to create table ${step.tableName}:`,
181
- result.error,
182
- );
189
+ console.error(`Failed to create table ${step.tableName}:`, result.error);
183
190
  throw new Error(`Migration failed: ${result.error}`);
184
191
  }
185
192
  } else if (step.operation === "update") {
@@ -190,10 +197,7 @@ export async function executeMigration(
190
197
  });
191
198
 
192
199
  if (result.error) {
193
- console.error(
194
- `Failed to update table ${step.tableName}:`,
195
- result.error,
196
- );
200
+ console.error(`Failed to update table ${step.tableName}:`, result.error);
197
201
  throw new Error(`Migration failed: ${result.error}`);
198
202
  }
199
203
  }
@@ -274,8 +278,12 @@ export function prettyPrintMigrationPlan(migrationPlan: MigrationPlan) {
274
278
  if (step.fields.length) {
275
279
  for (const field of step.fields) {
276
280
  let fieldDesc = ` - ${field.name} (${field.type}`;
277
- if (field.primary) fieldDesc += ", primary";
278
- if (field.unique) fieldDesc += ", unique";
281
+ if (field.primary) {
282
+ fieldDesc += ", primary";
283
+ }
284
+ if (field.unique) {
285
+ fieldDesc += ", unique";
286
+ }
279
287
  fieldDesc += ")";
280
288
  console.log(fieldDesc);
281
289
  }
@@ -1,22 +1,23 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: library code */
1
2
  import { logger as betterAuthLogger } from "better-auth";
2
- import { err, ok, Result } from "neverthrow";
3
- import { z } from "zod/v4";
3
+ import { err, ok, type Result } from "neverthrow";
4
+ import type { z } from "zod/v4";
4
5
 
5
- type BasicAuthCredentials = {
6
+ interface BasicAuthCredentials {
6
7
  username: string;
7
8
  password: string;
8
- };
9
- type OttoAPIKeyAuth = {
9
+ }
10
+ interface OttoAPIKeyAuth {
10
11
  apiKey: string;
11
- };
12
+ }
12
13
  type ODataAuth = BasicAuthCredentials | OttoAPIKeyAuth;
13
14
 
14
- export type FmOdataConfig = {
15
+ export interface FmOdataConfig {
15
16
  serverUrl: string;
16
17
  auth: ODataAuth;
17
18
  database: string;
18
19
  logging?: true | "verbose" | "none";
19
- };
20
+ }
20
21
 
21
22
  export function validateUrl(input: string): Result<URL, unknown> {
22
23
  try {
@@ -36,7 +37,7 @@ export function createRawFetch(args: FmOdataConfig) {
36
37
 
37
38
  let baseURL = result.value.origin;
38
39
  if ("apiKey" in args.auth) {
39
- baseURL += `/otto`;
40
+ baseURL += "/otto";
40
41
  }
41
42
  baseURL += `/fmi/odata/v4/${args.database}`;
42
43
 
@@ -63,9 +64,7 @@ export function createRawFetch(args: FmOdataConfig) {
63
64
  // Handle different input types
64
65
  if (typeof input === "string") {
65
66
  // If it's already a full URL, use as-is, otherwise prepend baseURL
66
- url = input.startsWith("http")
67
- ? input
68
- : `${baseURL}${input.startsWith("/") ? input : `/${input}`}`;
67
+ url = input.startsWith("http") ? input : `${baseURL}${input.startsWith("/") ? input : `/${input}`}`;
69
68
  } else if (input instanceof URL) {
70
69
  url = input.toString();
71
70
  } else if (input instanceof Request) {
@@ -101,10 +100,7 @@ export function createRawFetch(args: FmOdataConfig) {
101
100
 
102
101
  // Optional logging
103
102
  if (args.logging === "verbose" || args.logging === true) {
104
- betterAuthLogger.info(
105
- "raw-fetch",
106
- `${requestInit.method || "GET"} ${url}`,
107
- );
103
+ betterAuthLogger.info("raw-fetch", `${requestInit.method || "GET"} ${url}`);
108
104
  if (requestInit.body) {
109
105
  betterAuthLogger.info("raw-fetch", "Request body:", requestInit.body);
110
106
  }
@@ -114,25 +110,15 @@ export function createRawFetch(args: FmOdataConfig) {
114
110
 
115
111
  // Optional logging for response details
116
112
  if (args.logging === "verbose" || args.logging === true) {
117
- betterAuthLogger.info(
118
- "raw-fetch",
119
- `Response status: ${response.status} ${response.statusText}`,
120
- );
121
- betterAuthLogger.info(
122
- "raw-fetch",
123
- `Response headers:`,
124
- Object.fromEntries(response.headers.entries()),
125
- );
113
+ betterAuthLogger.info("raw-fetch", `Response status: ${response.status} ${response.statusText}`);
114
+ betterAuthLogger.info("raw-fetch", "Response headers:", Object.fromEntries(response.headers.entries()));
126
115
  }
127
116
 
128
117
  // Check if response is ok
129
118
  if (!response.ok) {
130
119
  const errorText = await response.text().catch(() => "Unknown error");
131
120
  if (args.logging === "verbose" || args.logging === true) {
132
- betterAuthLogger.error(
133
- "raw-fetch",
134
- `HTTP Error ${response.status}: ${errorText}`,
135
- );
121
+ betterAuthLogger.error("raw-fetch", `HTTP Error ${response.status}: ${errorText}`);
136
122
  }
137
123
  return {
138
124
  error: `HTTP ${response.status}: ${errorText}`,
@@ -145,51 +131,32 @@ export function createRawFetch(args: FmOdataConfig) {
145
131
  const contentType = response.headers.get("content-type");
146
132
 
147
133
  if (args.logging === "verbose" || args.logging === true) {
148
- betterAuthLogger.info(
149
- "raw-fetch",
150
- `Response content-type: ${contentType || "none"}`,
151
- );
134
+ betterAuthLogger.info("raw-fetch", `Response content-type: ${contentType || "none"}`);
152
135
  }
153
136
 
154
137
  if (contentType?.includes("application/json")) {
155
138
  try {
156
139
  const responseText = await response.text();
157
140
  if (args.logging === "verbose" || args.logging === true) {
158
- betterAuthLogger.info(
159
- "raw-fetch",
160
- `Raw response text: "${responseText}"`,
161
- );
162
- betterAuthLogger.info(
163
- "raw-fetch",
164
- `Response text length: ${responseText.length}`,
165
- );
141
+ betterAuthLogger.info("raw-fetch", `Raw response text: "${responseText}"`);
142
+ betterAuthLogger.info("raw-fetch", `Response text length: ${responseText.length}`);
166
143
  }
167
144
 
168
145
  // Handle empty responses
169
146
  if (responseText.trim() === "") {
170
147
  if (args.logging === "verbose" || args.logging === true) {
171
- betterAuthLogger.info(
172
- "raw-fetch",
173
- "Empty JSON response, returning null",
174
- );
148
+ betterAuthLogger.info("raw-fetch", "Empty JSON response, returning null");
175
149
  }
176
150
  responseData = null;
177
151
  } else {
178
152
  responseData = JSON.parse(responseText);
179
153
  if (args.logging === "verbose" || args.logging === true) {
180
- betterAuthLogger.info(
181
- "raw-fetch",
182
- "Successfully parsed JSON response",
183
- );
154
+ betterAuthLogger.info("raw-fetch", "Successfully parsed JSON response");
184
155
  }
185
156
  }
186
157
  } catch (parseError) {
187
158
  if (args.logging === "verbose" || args.logging === true) {
188
- betterAuthLogger.error(
189
- "raw-fetch",
190
- "JSON parse error:",
191
- parseError,
192
- );
159
+ betterAuthLogger.error("raw-fetch", "JSON parse error:", parseError);
193
160
  }
194
161
  return {
195
162
  error: `Failed to parse JSON response: ${parseError instanceof Error ? parseError.message : "Unknown parse error"}`,
@@ -200,29 +167,20 @@ export function createRawFetch(args: FmOdataConfig) {
200
167
  // Handle text responses (text/plain, text/html, etc.)
201
168
  responseData = await response.text();
202
169
  if (args.logging === "verbose" || args.logging === true) {
203
- betterAuthLogger.info(
204
- "raw-fetch",
205
- `Text response: "${responseData}"`,
206
- );
170
+ betterAuthLogger.info("raw-fetch", `Text response: "${responseData}"`);
207
171
  }
208
172
  } else {
209
173
  // For other content types, try to get text but don't fail if it's binary
210
174
  try {
211
175
  responseData = await response.text();
212
176
  if (args.logging === "verbose" || args.logging === true) {
213
- betterAuthLogger.info(
214
- "raw-fetch",
215
- `Unknown content-type response as text: "${responseData}"`,
216
- );
177
+ betterAuthLogger.info("raw-fetch", `Unknown content-type response as text: "${responseData}"`);
217
178
  }
218
179
  } catch {
219
180
  // If text parsing fails (e.g., binary data), return null
220
181
  responseData = null;
221
182
  if (args.logging === "verbose" || args.logging === true) {
222
- betterAuthLogger.info(
223
- "raw-fetch",
224
- "Could not parse response as text, returning null",
225
- );
183
+ betterAuthLogger.info("raw-fetch", "Could not parse response as text, returning null");
226
184
  }
227
185
  }
228
186
  }
@@ -235,12 +193,11 @@ export function createRawFetch(args: FmOdataConfig) {
235
193
  data: validation.data,
236
194
  response,
237
195
  };
238
- } else {
239
- return {
240
- error: `Validation failed: ${validation.error.message}`,
241
- response,
242
- };
243
196
  }
197
+ return {
198
+ error: `Validation failed: ${validation.error.message}`,
199
+ response,
200
+ };
244
201
  }
245
202
 
246
203
  // Return unvalidated data
@@ -250,8 +207,7 @@ export function createRawFetch(args: FmOdataConfig) {
250
207
  };
251
208
  } catch (error) {
252
209
  return {
253
- error:
254
- error instanceof Error ? error.message : "Unknown error occurred",
210
+ error: error instanceof Error ? error.message : "Unknown error occurred",
255
211
  };
256
212
  }
257
213
  };