@better-auth/core 1.7.2 → 1.7.3
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/dist/api/index.d.mts +3 -0
- package/dist/context/global.mjs +1 -1
- package/dist/context/transaction.mjs +3 -0
- package/dist/db/adapter/atomic-fallback.mjs +134 -0
- package/dist/db/adapter/factory.mjs +22 -4
- package/dist/db/adapter/index.d.mts +15 -11
- package/dist/db/get-tables.mjs +1 -9
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +2 -2
- package/dist/db/internal.d.mts +3 -1
- package/dist/db/internal.mjs +3 -1
- package/dist/db/schema/account.d.mts +2 -13
- package/dist/db/schema/account.mjs +1 -19
- package/dist/db/schema-check.d.mts +48 -0
- package/dist/db/schema-check.mjs +80 -0
- package/dist/db/schema-diff.d.mts +104 -0
- package/dist/db/schema-diff.mjs +154 -0
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/oauth-provider.d.mts +0 -10
- package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
- package/dist/oauth2/token-endpoint-auth.mjs +11 -0
- package/dist/social-providers/apple.d.mts +0 -1
- package/dist/social-providers/apple.mjs +0 -1
- package/dist/social-providers/cloudflare.d.mts +132 -0
- package/dist/social-providers/cloudflare.mjs +85 -0
- package/dist/social-providers/cognito.d.mts +0 -1
- package/dist/social-providers/cognito.mjs +0 -1
- package/dist/social-providers/facebook.d.mts +0 -1
- package/dist/social-providers/facebook.mjs +0 -1
- package/dist/social-providers/google.d.mts +0 -1
- package/dist/social-providers/google.mjs +0 -1
- package/dist/social-providers/index.d.mts +53 -21
- package/dist/social-providers/index.mjs +3 -1
- package/dist/social-providers/line.d.mts +0 -1
- package/dist/social-providers/line.mjs +0 -1
- package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
- package/dist/social-providers/microsoft-entra-id.mjs +0 -1
- package/dist/social-providers/paybin.d.mts +0 -1
- package/dist/social-providers/paybin.mjs +0 -1
- package/dist/social-providers/paypal.d.mts +3 -11
- package/dist/social-providers/paypal.mjs +20 -47
- package/dist/social-providers/reddit.mjs +17 -22
- package/dist/social-providers/tiktok.d.mts +1 -0
- package/dist/social-providers/tiktok.mjs +14 -9
- package/dist/types/context.d.mts +11 -0
- package/dist/types/init-options.d.mts +11 -0
- package/dist/utils/ip.mjs +11 -9
- package/package.json +2 -2
- package/src/context/transaction.ts +5 -0
- package/src/db/adapter/atomic-fallback.ts +237 -0
- package/src/db/adapter/factory.ts +33 -17
- package/src/db/adapter/index.ts +15 -11
- package/src/db/get-tables.ts +1 -14
- package/src/db/index.ts +0 -2
- package/src/db/internal.ts +19 -0
- package/src/db/schema/account.ts +3 -22
- package/src/db/schema/user.ts +1 -1
- package/src/db/schema-check.ts +107 -0
- package/src/db/schema-diff.ts +270 -0
- package/src/oauth2/index.ts +2 -0
- package/src/oauth2/oauth-provider.ts +0 -10
- package/src/oauth2/token-endpoint-auth.ts +39 -6
- package/src/social-providers/apple.ts +0 -1
- package/src/social-providers/cloudflare.ts +221 -0
- package/src/social-providers/cognito.ts +0 -1
- package/src/social-providers/facebook.ts +0 -1
- package/src/social-providers/google.ts +0 -1
- package/src/social-providers/index.ts +3 -0
- package/src/social-providers/line.ts +0 -1
- package/src/social-providers/microsoft-entra-id.ts +0 -1
- package/src/social-providers/paybin.ts +0 -1
- package/src/social-providers/paypal.ts +30 -71
- package/src/social-providers/reddit.ts +27 -36
- package/src/social-providers/tiktok.ts +18 -13
- package/src/types/context.ts +11 -0
- package/src/types/init-options.ts +11 -0
- package/src/utils/ip.ts +13 -9
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { BetterAuthError } from "../../error";
|
|
3
|
+
import type { CleanedWhere, CustomAdapter, Where } from "./index";
|
|
4
|
+
|
|
5
|
+
const MAX_ATTEMPTS = 5;
|
|
6
|
+
|
|
7
|
+
const scalar = z
|
|
8
|
+
.union([z.string(), z.number(), z.boolean(), z.date()])
|
|
9
|
+
.nullable();
|
|
10
|
+
const rowSchema = z.record(z.string(), z.unknown());
|
|
11
|
+
const readSchema = rowSchema.nullish();
|
|
12
|
+
const setSchema = z.record(z.string(), z.unknown()).transform((values) => {
|
|
13
|
+
const assignments: z.output<typeof rowSchema> = {};
|
|
14
|
+
for (const [field, value] of Object.entries(values)) {
|
|
15
|
+
if (value !== undefined) assignments[field] = value;
|
|
16
|
+
}
|
|
17
|
+
return assignments;
|
|
18
|
+
});
|
|
19
|
+
const mutationSchema = z.object({
|
|
20
|
+
increment: z.record(z.string(), z.number()),
|
|
21
|
+
set: setSchema.optional(),
|
|
22
|
+
});
|
|
23
|
+
const counterSchema = z.number().nullish();
|
|
24
|
+
|
|
25
|
+
type StoredRow = z.output<typeof rowSchema>;
|
|
26
|
+
|
|
27
|
+
type FallbackContext = {
|
|
28
|
+
adapter: CustomAdapter;
|
|
29
|
+
adapterId: string;
|
|
30
|
+
mapKeysTransformInput?: Record<string, string> | undefined;
|
|
31
|
+
mapKeysTransformOutput?: Record<string, string> | undefined;
|
|
32
|
+
getFieldName: (input: { model: string; field: string }) => string;
|
|
33
|
+
transformOutput: (
|
|
34
|
+
row: StoredRow,
|
|
35
|
+
model: string,
|
|
36
|
+
select: string[],
|
|
37
|
+
join: undefined,
|
|
38
|
+
) => Promise<{ id?: unknown } | null>;
|
|
39
|
+
transformWhereClause: (input: {
|
|
40
|
+
model: string;
|
|
41
|
+
where: Where[];
|
|
42
|
+
action: "consumeOne" | "incrementOne";
|
|
43
|
+
}) => CleanedWhere[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type FallbackRequest = {
|
|
47
|
+
model: string;
|
|
48
|
+
logicalModel: string;
|
|
49
|
+
where: CleanedWhere[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Read a snapshot, then conditionally mutate it. A write succeeds only when
|
|
53
|
+
// an adapter checks and mutates atomically and reports exactly one affected row.
|
|
54
|
+
export function createAtomicFallbacks(context: FallbackContext) {
|
|
55
|
+
const { adapter, adapterId } = context;
|
|
56
|
+
const outputId =
|
|
57
|
+
Object.entries(context.mapKeysTransformOutput ?? {}).find(
|
|
58
|
+
([, field]) => field === "id",
|
|
59
|
+
)?.[0] ?? "id";
|
|
60
|
+
async function idWhere(
|
|
61
|
+
row: StoredRow,
|
|
62
|
+
model: string,
|
|
63
|
+
action: "consumeOne" | "incrementOne",
|
|
64
|
+
): Promise<CleanedWhere> {
|
|
65
|
+
const mappedId =
|
|
66
|
+
context.mapKeysTransformInput?.id ||
|
|
67
|
+
context.getFieldName({ model, field: "id" });
|
|
68
|
+
if (row[mappedId] === undefined || row[mappedId] === null) {
|
|
69
|
+
throw new BetterAuthError(
|
|
70
|
+
`Adapter "${context.adapterId}" must return the row id for atomic fallbacks.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const output = await context.transformOutput(
|
|
74
|
+
row,
|
|
75
|
+
model,
|
|
76
|
+
[outputId],
|
|
77
|
+
undefined,
|
|
78
|
+
);
|
|
79
|
+
const id = output?.id;
|
|
80
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
81
|
+
throw new BetterAuthError(
|
|
82
|
+
`Adapter "${context.adapterId}" must expose a logical string or number id through its output transform.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const [condition] = context.transformWhereClause({
|
|
86
|
+
model,
|
|
87
|
+
where: [{ field: "id", value: id }],
|
|
88
|
+
action,
|
|
89
|
+
});
|
|
90
|
+
if (!condition)
|
|
91
|
+
throw new BetterAuthError(
|
|
92
|
+
"The atomic fallback id condition was transformed away.",
|
|
93
|
+
);
|
|
94
|
+
return condition;
|
|
95
|
+
}
|
|
96
|
+
async function readRow({
|
|
97
|
+
model,
|
|
98
|
+
where,
|
|
99
|
+
}: FallbackRequest): Promise<StoredRow | null> {
|
|
100
|
+
const result = readSchema.safeParse(
|
|
101
|
+
await adapter.findOne<unknown>({ model, where }),
|
|
102
|
+
);
|
|
103
|
+
if (!result.success) {
|
|
104
|
+
throw new BetterAuthError(
|
|
105
|
+
`Adapter "${adapterId}" must return a row snapshot or null.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return result.data ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function snapshotGuard(
|
|
112
|
+
row: StoredRow,
|
|
113
|
+
fields: readonly string[],
|
|
114
|
+
request: FallbackRequest,
|
|
115
|
+
action: "consumeOne" | "incrementOne",
|
|
116
|
+
): Promise<CleanedWhere[]> {
|
|
117
|
+
const id = await idWhere(row, request.logicalModel, action);
|
|
118
|
+
const hasOr = request.where.some((clause) => clause.connector === "OR");
|
|
119
|
+
const guard: CleanedWhere[] = hasOr ? [id] : [...request.where, id];
|
|
120
|
+
const keys = new Set(fields);
|
|
121
|
+
for (const field of keys) {
|
|
122
|
+
if (field === id.field) continue;
|
|
123
|
+
const value = scalar.safeParse(row[field] ?? null);
|
|
124
|
+
if (!value.success) {
|
|
125
|
+
if (hasOr && request.where.some((clause) => clause.field === field)) {
|
|
126
|
+
throw new BetterAuthError(
|
|
127
|
+
`Adapter "${adapterId}" must implement native atomic methods for OR predicates on structured values.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
guard.push({
|
|
133
|
+
field,
|
|
134
|
+
value: value.data,
|
|
135
|
+
operator: "eq",
|
|
136
|
+
connector: "AND",
|
|
137
|
+
mode: "sensitive",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return guard;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function changedOne(count: number): boolean {
|
|
144
|
+
if (count !== 0 && count !== 1) {
|
|
145
|
+
throw new BetterAuthError(
|
|
146
|
+
`Adapter "${adapterId}" must return an affected row count of 0 or 1 from an atomic fallback.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return count === 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function consumeOne(
|
|
153
|
+
request: FallbackRequest,
|
|
154
|
+
): Promise<StoredRow | null> {
|
|
155
|
+
const { model, where } = request;
|
|
156
|
+
const row = await readRow(request);
|
|
157
|
+
if (row === null) return null;
|
|
158
|
+
// Guard the selected snapshot with AND predicates, without widening an OR selector.
|
|
159
|
+
const guard = await snapshotGuard(
|
|
160
|
+
row,
|
|
161
|
+
[...Object.keys(row), ...where.map(({ field }) => field)],
|
|
162
|
+
request,
|
|
163
|
+
"consumeOne",
|
|
164
|
+
);
|
|
165
|
+
const count = await adapter.deleteMany({ model, where: guard });
|
|
166
|
+
return changedOne(count) ? row : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function incrementOne(
|
|
170
|
+
request: FallbackRequest & {
|
|
171
|
+
increment: Record<string, number>;
|
|
172
|
+
set?: Record<string, unknown> | undefined;
|
|
173
|
+
},
|
|
174
|
+
): Promise<StoredRow | null> {
|
|
175
|
+
const { model, where } = request;
|
|
176
|
+
const mutation = mutationSchema.safeParse(request);
|
|
177
|
+
if (!mutation.success) {
|
|
178
|
+
throw new BetterAuthError(
|
|
179
|
+
"incrementOne requires finite increments and a set object for the atomic fallback.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const { increment, set } = mutation.data;
|
|
183
|
+
const deltas = Object.entries(increment);
|
|
184
|
+
const fields = [
|
|
185
|
+
...where.map(({ field }) => field),
|
|
186
|
+
...Object.keys(increment),
|
|
187
|
+
...Object.keys(set ?? {}),
|
|
188
|
+
];
|
|
189
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
190
|
+
const row = await readRow(request);
|
|
191
|
+
if (row === null) return null;
|
|
192
|
+
const update: z.output<typeof setSchema> = { ...set };
|
|
193
|
+
for (const [field, delta] of deltas) {
|
|
194
|
+
const previous = counterSchema.safeParse(row[field]);
|
|
195
|
+
if (!previous.success) {
|
|
196
|
+
throw new BetterAuthError(
|
|
197
|
+
`Adapter "${adapterId}" must return finite numeric counter values or null for atomic increments.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const current = previous.data ?? 0;
|
|
201
|
+
const next = current + delta;
|
|
202
|
+
if (!Number.isFinite(next) || (delta !== 0 && next === current)) {
|
|
203
|
+
throw new BetterAuthError(
|
|
204
|
+
`Adapter "${adapterId}" cannot represent the requested counter increment safely.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
update[field] = next;
|
|
208
|
+
}
|
|
209
|
+
const guard = await snapshotGuard(row, fields, request, "incrementOne");
|
|
210
|
+
// A no-op takes effect at the read. Stores counting changed rows would report zero.
|
|
211
|
+
if (
|
|
212
|
+
Object.entries(update).every(([field, value]) => {
|
|
213
|
+
const previous = row[field];
|
|
214
|
+
if (previous instanceof Date && value instanceof Date) {
|
|
215
|
+
return previous.getTime() === value.getTime();
|
|
216
|
+
}
|
|
217
|
+
return Object.is(previous, value);
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
return row;
|
|
221
|
+
const count = await adapter.updateMany({
|
|
222
|
+
model,
|
|
223
|
+
where: guard,
|
|
224
|
+
update,
|
|
225
|
+
});
|
|
226
|
+
if (changedOne(count)) {
|
|
227
|
+
// A second read could observe another writer's result instead of ours.
|
|
228
|
+
return { ...row, ...update };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
throw new BetterAuthError(
|
|
232
|
+
`Adapter "${adapterId}" could not complete an atomic increment due to contention. Retry the operation or implement incrementOne natively.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { consumeOne, incrementOne };
|
|
237
|
+
}
|
|
@@ -8,6 +8,7 @@ import { BetterAuthError } from "../../error";
|
|
|
8
8
|
import type { BetterAuthOptions } from "../../types";
|
|
9
9
|
import { safeJSONParse } from "../../utils/json";
|
|
10
10
|
import { getAuthTables } from "../get-tables";
|
|
11
|
+
import { createAtomicFallbacks } from "./atomic-fallback";
|
|
11
12
|
import { initGetDefaultFieldName } from "./get-default-field-name";
|
|
12
13
|
import { initGetDefaultModelName } from "./get-default-model-name";
|
|
13
14
|
import { initGetFieldAttributes } from "./get-field-attributes";
|
|
@@ -841,6 +842,16 @@ export const createAdapterFactory =
|
|
|
841
842
|
});
|
|
842
843
|
|
|
843
844
|
let lazyLoadTransaction: DBAdapter<Options>["transaction"] | null = null;
|
|
845
|
+
const atomicFallbacks = createAtomicFallbacks({
|
|
846
|
+
adapter: adapterInstance,
|
|
847
|
+
adapterId: config.adapterId,
|
|
848
|
+
mapKeysTransformInput: config.mapKeysTransformInput,
|
|
849
|
+
mapKeysTransformOutput: config.mapKeysTransformOutput,
|
|
850
|
+
getFieldName,
|
|
851
|
+
transformOutput,
|
|
852
|
+
transformWhereClause,
|
|
853
|
+
});
|
|
854
|
+
|
|
844
855
|
const adapter: DBAdapter<Options> = {
|
|
845
856
|
transaction: async (cb) => {
|
|
846
857
|
if (!lazyLoadTransaction) {
|
|
@@ -1366,18 +1377,20 @@ export const createAdapterFactory =
|
|
|
1366
1377
|
{ model, where },
|
|
1367
1378
|
);
|
|
1368
1379
|
|
|
1369
|
-
if (typeof adapterInstance.consumeOne !== "function") {
|
|
1370
|
-
throw new BetterAuthError(
|
|
1371
|
-
`Adapter "${config.adapterId}" must implement consumeOne for atomic single-use credential consumption.`,
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
1380
|
const res = await withSpan(
|
|
1375
1381
|
`db consumeOne ${model}`,
|
|
1376
1382
|
{
|
|
1377
1383
|
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
1378
1384
|
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1379
1385
|
},
|
|
1380
|
-
() =>
|
|
1386
|
+
() =>
|
|
1387
|
+
adapterInstance.consumeOne
|
|
1388
|
+
? adapterInstance.consumeOne<T>({ model, where })
|
|
1389
|
+
: atomicFallbacks.consumeOne({
|
|
1390
|
+
model,
|
|
1391
|
+
logicalModel: unsafeModel,
|
|
1392
|
+
where,
|
|
1393
|
+
}),
|
|
1381
1394
|
);
|
|
1382
1395
|
|
|
1383
1396
|
debugLog(
|
|
@@ -1440,11 +1453,6 @@ export const createAdapterFactory =
|
|
|
1440
1453
|
{ model, where, increment: unsafeIncrement, set: unsafeSet },
|
|
1441
1454
|
);
|
|
1442
1455
|
|
|
1443
|
-
if (typeof adapterInstance.incrementOne !== "function") {
|
|
1444
|
-
throw new BetterAuthError(
|
|
1445
|
-
`Adapter "${config.adapterId}" must implement incrementOne for atomic guarded counter updates.`,
|
|
1446
|
-
);
|
|
1447
|
-
}
|
|
1448
1456
|
const mappedKeys = config.mapKeysTransformInput ?? {};
|
|
1449
1457
|
const increment: Record<string, number> = {};
|
|
1450
1458
|
for (const [field, delta] of Object.entries(unsafeIncrement)) {
|
|
@@ -1473,12 +1481,20 @@ export const createAdapterFactory =
|
|
|
1473
1481
|
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1474
1482
|
},
|
|
1475
1483
|
() =>
|
|
1476
|
-
adapterInstance.incrementOne
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1484
|
+
adapterInstance.incrementOne
|
|
1485
|
+
? adapterInstance.incrementOne<T>({
|
|
1486
|
+
model,
|
|
1487
|
+
where,
|
|
1488
|
+
increment,
|
|
1489
|
+
set,
|
|
1490
|
+
})
|
|
1491
|
+
: atomicFallbacks.incrementOne({
|
|
1492
|
+
model,
|
|
1493
|
+
logicalModel: unsafeModel,
|
|
1494
|
+
where,
|
|
1495
|
+
increment,
|
|
1496
|
+
set,
|
|
1497
|
+
}),
|
|
1482
1498
|
);
|
|
1483
1499
|
|
|
1484
1500
|
debugLog(
|
package/src/db/adapter/index.ts
CHANGED
|
@@ -466,9 +466,9 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
466
466
|
* race-safe primitive for consuming single-use credentials
|
|
467
467
|
* (verification tokens, authorization codes, one-time tokens).
|
|
468
468
|
*
|
|
469
|
-
* Always defined on the factory-wrapped adapter.
|
|
470
|
-
*
|
|
471
|
-
*
|
|
469
|
+
* Always defined on the factory-wrapped adapter. Without a native method,
|
|
470
|
+
* the factory uses a snapshot-guarded delete and requires an exact affected
|
|
471
|
+
* row count. The adapter must evaluate the condition and deletion atomically.
|
|
472
472
|
*/
|
|
473
473
|
consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
|
|
474
474
|
/**
|
|
@@ -490,9 +490,11 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
490
490
|
* primitive for guarded counter updates (e.g. decrementing a remaining-uses
|
|
491
491
|
* counter only while it is still positive).
|
|
492
492
|
*
|
|
493
|
-
* Always defined on the factory-wrapped adapter.
|
|
494
|
-
*
|
|
495
|
-
*
|
|
493
|
+
* Always defined on the factory-wrapped adapter. Without a native method,
|
|
494
|
+
* the factory uses bounded compare-and-swap retries. Contention exhaustion
|
|
495
|
+
* throws rather than returning null. Conditional writes must be atomic.
|
|
496
|
+
* A no-op may return the read snapshot without writing. A non-null result
|
|
497
|
+
* alone does not establish exclusive ownership of the row.
|
|
496
498
|
*/
|
|
497
499
|
incrementOne: <T>(data: {
|
|
498
500
|
model: string;
|
|
@@ -587,19 +589,21 @@ export interface CustomAdapter {
|
|
|
587
589
|
where: CleanedWhere[];
|
|
588
590
|
}) => Promise<number>;
|
|
589
591
|
/**
|
|
590
|
-
*
|
|
592
|
+
* Optional native atomic single-row consume.
|
|
593
|
+
*
|
|
591
594
|
* Implementing this method natively (e.g. `DELETE ... RETURNING *`,
|
|
592
595
|
* `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
|
|
593
596
|
* strongest race-safety guarantee. Implementations must delete at most
|
|
594
597
|
* one matching row.
|
|
595
598
|
*/
|
|
596
|
-
consumeOne
|
|
599
|
+
consumeOne?: <T>(data: {
|
|
597
600
|
model: string;
|
|
598
601
|
where: CleanedWhere[];
|
|
599
602
|
}) => Promise<T | null>;
|
|
600
603
|
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
604
|
+
* Optional native atomic guarded counter mutation.
|
|
605
|
+
*
|
|
606
|
+
* Applies `field = field + delta` for each entry in `increment` (negative deltas
|
|
603
607
|
* decrement), with `where` acting as both selector and guard and `set`
|
|
604
608
|
* assigning absolute values in the same operation. Returns the updated row,
|
|
605
609
|
* or `null` when the guard matched no row.
|
|
@@ -608,7 +612,7 @@ export interface CustomAdapter {
|
|
|
608
612
|
* RETURNING *`) gives one round trip and the strongest race-safety
|
|
609
613
|
* guarantee.
|
|
610
614
|
*/
|
|
611
|
-
incrementOne
|
|
615
|
+
incrementOne?: <T>(data: {
|
|
612
616
|
model: string;
|
|
613
617
|
where: CleanedWhere[];
|
|
614
618
|
increment: Record<string, number>;
|
package/src/db/get-tables.ts
CHANGED
|
@@ -250,21 +250,8 @@ const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => {
|
|
|
250
250
|
: {}),
|
|
251
251
|
account: {
|
|
252
252
|
modelName: options.account?.modelName || "account",
|
|
253
|
-
indexes:
|
|
254
|
-
[
|
|
255
|
-
{
|
|
256
|
-
fields: ["issuer", "accountId"],
|
|
257
|
-
unique: true,
|
|
258
|
-
},
|
|
259
|
-
],
|
|
260
|
-
account?.indexes,
|
|
261
|
-
),
|
|
253
|
+
indexes: account?.indexes,
|
|
262
254
|
fields: {
|
|
263
|
-
issuer: {
|
|
264
|
-
type: "string",
|
|
265
|
-
required: true,
|
|
266
|
-
fieldName: options.account?.fields?.issuer || "issuer",
|
|
267
|
-
},
|
|
268
255
|
accountId: {
|
|
269
256
|
type: "string",
|
|
270
257
|
required: true,
|
package/src/db/index.ts
CHANGED
package/src/db/internal.ts
CHANGED
|
@@ -10,3 +10,22 @@ export {
|
|
|
10
10
|
resolveDatabaseTableIndexes,
|
|
11
11
|
} from "./database-index";
|
|
12
12
|
export { getAuthTablesWithResolvedIndexes } from "./get-tables";
|
|
13
|
+
export {
|
|
14
|
+
checksSchema,
|
|
15
|
+
createSchemaCheck,
|
|
16
|
+
invalidateSchemaChecks,
|
|
17
|
+
registerSchemaCheck,
|
|
18
|
+
type SchemaCheck,
|
|
19
|
+
schemaCheckFor,
|
|
20
|
+
} from "./schema-check";
|
|
21
|
+
export {
|
|
22
|
+
diffSchema,
|
|
23
|
+
type ExpectedSchema,
|
|
24
|
+
formatSchemaFinding,
|
|
25
|
+
getExpectedSchema,
|
|
26
|
+
type IntrospectedColumn,
|
|
27
|
+
type IntrospectedTable,
|
|
28
|
+
type SchemaFinding,
|
|
29
|
+
SchemaMismatchError,
|
|
30
|
+
type SchemaSource,
|
|
31
|
+
} from "./schema-diff";
|
package/src/db/schema/account.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { coreSchema } from "./shared";
|
|
|
9
9
|
|
|
10
10
|
export const accountSchema = coreSchema.extend({
|
|
11
11
|
providerId: z.string(),
|
|
12
|
-
issuer: z.string(),
|
|
13
12
|
accountId: z.string(),
|
|
14
13
|
userId: z.coerce.string(),
|
|
15
14
|
accessToken: z.string().nullish(),
|
|
@@ -39,27 +38,9 @@ export const accountSchema = coreSchema.extend({
|
|
|
39
38
|
export type BaseAccount = z.infer<typeof accountSchema>;
|
|
40
39
|
|
|
41
40
|
/** The stable provider-side key used to recognize an account. */
|
|
42
|
-
export type AccountKey = Readonly<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return encodeURIComponent(providerId);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Creates the synthetic issuer used by providers without an issuer of their own.
|
|
50
|
-
*/
|
|
51
|
-
export function createLocalAccountIssuer(providerId: string): string {
|
|
52
|
-
return `local:${encodeAccountIssuerProviderId(providerId)}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Creates the synthetic issuer used by OAuth providers without an issuer of
|
|
57
|
-
* their own. OAuth identities use a distinct namespace so a provider ID
|
|
58
|
-
* cannot collide with an internal local authentication method.
|
|
59
|
-
*/
|
|
60
|
-
export function createOAuthAccountIssuer(providerId: string): string {
|
|
61
|
-
return `local:oauth:${encodeAccountIssuerProviderId(providerId)}`;
|
|
62
|
-
}
|
|
41
|
+
export type AccountKey = Readonly<
|
|
42
|
+
Pick<BaseAccount, "providerId" | "accountId">
|
|
43
|
+
>;
|
|
63
44
|
|
|
64
45
|
/**
|
|
65
46
|
* Account schema type used by better-auth, note that it's possible that account could have additional fields
|
package/src/db/schema/user.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { coreSchema } from "./shared";
|
|
|
9
9
|
export const userSchema = coreSchema.extend({
|
|
10
10
|
// TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
|
|
11
11
|
// Apple subsequent sign-ins, etc.) can legitimately omit email; identity
|
|
12
|
-
// must key on (
|
|
12
|
+
// must key on (providerId, accountId) per OpenID Connect Core §5.7.
|
|
13
13
|
email: z.string().transform((val) => val.toLowerCase()),
|
|
14
14
|
emailVerified: z.boolean().default(false),
|
|
15
15
|
name: z.string(),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal schema validation infrastructure for built-in adapters.
|
|
3
|
+
*
|
|
4
|
+
* Intended to become a public core extension point for community database
|
|
5
|
+
* adapter authors once the registration and lifecycle contracts are stabilized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BetterAuthOptions } from "../types";
|
|
9
|
+
import type { SchemaFinding, SchemaSource } from "./schema-diff";
|
|
10
|
+
import { SchemaMismatchError } from "./schema-diff";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the adapter validates its schema. Enabled in every environment
|
|
14
|
+
* unless explicitly disabled.
|
|
15
|
+
*/
|
|
16
|
+
export function checksSchema(options: BetterAuthOptions): boolean {
|
|
17
|
+
return options.advanced?.database?.validateSchema !== false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves when the schema can hold what Better Auth writes. Returns nothing
|
|
22
|
+
* once that is known and the database schema revision is unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export type SchemaCheck = () => Promise<void> | undefined;
|
|
25
|
+
|
|
26
|
+
const schemaChecks = new WeakMap<object, SchemaCheck>();
|
|
27
|
+
const schemaRevisions = new WeakMap<object, { value: number }>();
|
|
28
|
+
|
|
29
|
+
/** Invalidates cached checks after Better Auth changes this database's schema. */
|
|
30
|
+
export function invalidateSchemaChecks(database: object): void {
|
|
31
|
+
const revision = schemaRevisions.get(database);
|
|
32
|
+
if (revision) revision.value++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attaches a check to the adapter it verifies. The adapter object itself is
|
|
37
|
+
* left untouched, so this works for adapters Better Auth does not own.
|
|
38
|
+
*/
|
|
39
|
+
export function registerSchemaCheck(adapter: object, check: SchemaCheck): void {
|
|
40
|
+
schemaChecks.set(adapter, check);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The check registered for an adapter, if its store is checked at all.
|
|
45
|
+
*/
|
|
46
|
+
export function schemaCheckFor(adapter: object): SchemaCheck | undefined {
|
|
47
|
+
return schemaChecks.get(adapter);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Turns a schema comparison into a check shared by one adapter instance.
|
|
52
|
+
*
|
|
53
|
+
* The first call runs `find` and every concurrent call shares that promise. A
|
|
54
|
+
* clean result is cached until invalidation. A mismatch is kept as one
|
|
55
|
+
* {@link SchemaMismatchError} and rethrown on every later call without asking
|
|
56
|
+
* the store again, until a migration invalidates it. When a database identity is supplied,
|
|
57
|
+
* checks for that identity share its schema revision. Pending callers follow
|
|
58
|
+
* the new check if their revision is invalidated. A failure to reach
|
|
59
|
+
* the store is not kept, so the next call asks again.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const checkSchema = createSchemaCheck(
|
|
64
|
+
* () => findSchemaProblems(db, "postgres", expected),
|
|
65
|
+
* "database",
|
|
66
|
+
* );
|
|
67
|
+
* const pending = checkSchema();
|
|
68
|
+
* if (pending) await pending;
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function createSchemaCheck(
|
|
72
|
+
find: () => Promise<SchemaFinding[]>,
|
|
73
|
+
source: SchemaSource,
|
|
74
|
+
database?: object,
|
|
75
|
+
): SchemaCheck {
|
|
76
|
+
let revision = database ? schemaRevisions.get(database) : undefined;
|
|
77
|
+
if (database && !revision) {
|
|
78
|
+
revision = { value: 0 };
|
|
79
|
+
schemaRevisions.set(database, revision);
|
|
80
|
+
}
|
|
81
|
+
let checkedRevision = revision?.value;
|
|
82
|
+
let clean = false;
|
|
83
|
+
let verdict: Promise<void> | undefined;
|
|
84
|
+
return function checkSchema(): Promise<void> | undefined {
|
|
85
|
+
const currentRevision = revision?.value;
|
|
86
|
+
if (checkedRevision !== currentRevision) {
|
|
87
|
+
checkedRevision = currentRevision;
|
|
88
|
+
clean = false;
|
|
89
|
+
verdict = undefined;
|
|
90
|
+
}
|
|
91
|
+
if (clean) return;
|
|
92
|
+
return (verdict ??= Promise.resolve()
|
|
93
|
+
.then(find)
|
|
94
|
+
.then(
|
|
95
|
+
(findings) => {
|
|
96
|
+
if (revision?.value !== currentRevision) return checkSchema();
|
|
97
|
+
if (findings.length) throw new SchemaMismatchError(findings, source);
|
|
98
|
+
if (checkedRevision === currentRevision) clean = true;
|
|
99
|
+
},
|
|
100
|
+
(error: unknown) => {
|
|
101
|
+
if (revision?.value !== currentRevision) return checkSchema();
|
|
102
|
+
if (checkedRevision === currentRevision) verdict = undefined;
|
|
103
|
+
throw error;
|
|
104
|
+
},
|
|
105
|
+
));
|
|
106
|
+
};
|
|
107
|
+
}
|