@flaredev/cli 0.9.0 → 0.10.0

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.
@@ -1,154 +1,177 @@
1
- import { env, waitUntil } from "cloudflare:workers";
2
- import { passkey } from "@better-auth/passkey";
3
- import { drizzleAdapter } from "@better-auth/drizzle-adapter";
4
- import { betterAuth } from "better-auth";
5
- import { APIError, createAuthMiddleware } from "better-auth/api";
6
- import { nextCookies } from "better-auth/next-js";
7
- import { admin } from "better-auth/plugins/admin";
8
- import { emailOTP } from "better-auth/plugins/email-otp";
9
- import { magicLink } from "better-auth/plugins/magic-link";
10
- import { twoFactor } from "better-auth/plugins/two-factor";
11
- import { hashPassword, verifyPassword } from "@flaredev/core";
12
- import { getDb, schema } from "@/db";
13
- import { authConfig, type SocialProvider } from "./auth-config";
14
- import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
15
- import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
16
-
17
- const vars = env as unknown as Record<string, string | undefined>;
18
-
19
- /** Optional: set when serving from a custom domain (e.g. https://example.com). */
20
- const configuredURL = vars.BETTER_AUTH_URL;
21
-
22
- /** A provider's credentials from the environment, when both are set. */
23
- function credentials(prefix: string) {
24
- const clientId = vars[`${prefix}_CLIENT_ID`];
25
- const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
26
- return clientId && clientSecret ? { clientId, clientSecret } : undefined;
27
- }
28
-
29
- const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
30
- google: credentials("GOOGLE"),
31
- github: credentials("GITHUB"),
32
- // Apple's client secret is a JWT you generate from your key (see the auth guide).
33
- apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
34
- // "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
35
- microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
36
- };
37
-
38
- /** Social providers switched on in lib/auth-config.ts that also have credentials. */
39
- const socialProviders = Object.fromEntries(
40
- authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
41
- );
42
-
43
- /** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
44
- function disabledPath(path: string): boolean {
45
- if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
46
- if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
47
- if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
48
- const { authenticator, email } = authConfig.twoFactor;
49
- if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
50
- if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
51
- if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
52
- return false;
53
- }
54
-
55
- /** Where a new password arrives, and what it's called in the body. */
56
- const NEW_PASSWORD_FIELDS: Record<string, string> = {
57
- "/sign-up/email": "password",
58
- "/reset-password": "newPassword",
59
- "/change-password": "newPassword",
60
- };
61
-
62
- /**
63
- * The same password rules the sign-up and reset forms show as you type
64
- * (lib/password-rules.ts), enforced here so the API agrees with the screens.
65
- * Checked on the way in, before the password is hashed or stored.
66
- */
67
- async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
68
- const field = NEW_PASSWORD_FIELDS[path];
69
- if (!field) return;
70
- const password = body?.[field];
71
- if (typeof password !== "string") return;
72
-
73
- if (password.length > PASSWORD_MAX_LENGTH) {
74
- throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
75
- }
76
- const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
77
- if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
78
-
79
- // A password in a public breach list is already guessed, however strong it looks.
80
- if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
81
- throw new APIError("BAD_REQUEST", {
82
- message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
83
- });
84
- }
85
- }
86
-
87
- export const auth = betterAuth({
88
- appName: "__APP_NAME__",
89
- secret: env.BETTER_AUTH_SECRET,
90
- // Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
91
- // this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
92
- // deployed app never sees a trycloudflare.com host and the allowlist can't be spoofed.
93
- baseURL: configuredURL || {
94
- allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
95
- },
96
- // Sign in with Apple posts back from Apple's own origin.
97
- trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
98
- database: drizzleAdapter(getDb(), { provider: "sqlite", schema }),
99
- socialProviders,
100
- account: {
101
- // Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
102
- accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
103
- },
104
- emailAndPassword: {
105
- enabled: true,
106
- requireEmailVerification: authConfig.requireEmailVerification,
107
- // PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
108
- password: { hash: hashPassword, verify: verifyPassword },
109
- sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
110
- resetPasswordTokenExpiresIn: 60 * 60,
111
- // A password reset signs every other device out.
112
- revokeSessionsOnPasswordReset: true,
113
- },
114
- emailVerification: {
115
- sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
116
- sendOnSignUp: true,
117
- autoSignInAfterVerification: true,
118
- },
119
- advanced: {
120
- // Let emails and other deferred work finish after the response is sent.
121
- backgroundTasks: { handler: waitUntil },
122
- },
123
- hooks: {
124
- before: createAuthMiddleware(async (ctx) => {
125
- if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
126
- await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
127
- }),
128
- },
129
- plugins: [
130
- // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
131
- admin({ defaultRole: "user", adminRoles: ["admin"] }),
132
- magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
133
- emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
134
- twoFactor({
135
- issuer: "__APP_NAME__",
136
- otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
137
- // With only email codes switched on there's no authenticator app to confirm first.
138
- skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
139
- // People who signed up with a social account have no password to confirm with;
140
- // accounts that do have one must still give it.
141
- allowPasswordless: true,
142
- }),
143
- passkey({ rpName: "__APP_NAME__" }),
144
- // Must stay last: lets server actions set auth cookies.
145
- nextCookies(),
146
- ],
147
- });
148
-
149
- export type Session = typeof auth.$Infer.Session;
150
-
151
- /** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
152
- export function enabledSocialProviders(): SocialProvider[] {
153
- return Object.keys(socialProviders) as SocialProvider[];
154
- }
1
+ import { env, waitUntil } from "cloudflare:workers";
2
+ import { passkey } from "@better-auth/passkey";
3
+ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
4
+ import { betterAuth } from "better-auth";
5
+ import { APIError, createAuthMiddleware } from "better-auth/api";
6
+ import { nextCookies } from "better-auth/next-js";
7
+ import { admin } from "better-auth/plugins/admin";
8
+ import { emailOTP } from "better-auth/plugins/email-otp";
9
+ import { magicLink } from "better-auth/plugins/magic-link";
10
+ import { twoFactor } from "better-auth/plugins/two-factor";
11
+ import { hashPassword, verifyPassword } from "@flaredev/core";
12
+ import { getDb, schema } from "@/db";
13
+ import { authConfig, type SocialProvider } from "./auth-config";
14
+ import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
15
+ import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
16
+
17
+ const vars = env as unknown as Record<string, string | undefined>;
18
+
19
+ /**
20
+ * Optional: set when serving from a custom domain (e.g. https://example.com).
21
+ *
22
+ * Better Auth compares the request's Origin header against this string, and an Origin never
23
+ * carries a trailing slash. So `https://example.com/` — which is what you get copying a
24
+ * domain out of a browser bar or the Vercel dashboard — matches nothing, and every sign-in
25
+ * comes back 403 with nothing in the log saying why. Correct it and say so: a warning that
26
+ * leaves sign-in broken is not much better than the silence it replaces.
27
+ */
28
+ function authBaseURL(raw: string | undefined): string | undefined {
29
+ const value = raw?.trim();
30
+ if (!value) return undefined;
31
+ // Only a trailing slash is removed. A path stays: an app served under one legitimately
32
+ // sets BETTER_AUTH_URL to https://example.com/app.
33
+ const url = value.replace(/\/+$/, "");
34
+ if (url !== value) {
35
+ console.warn(`[auth] BETTER_AUTH_URL has a trailing slash, which never matches an Origin header and makes every sign-in 403. Using "${url}" — set BETTER_AUTH_URL to that to silence this.`);
36
+ }
37
+ if (url && !/^https?:\/\//.test(url)) {
38
+ console.warn(`[auth] BETTER_AUTH_URL ("${url}") has no scheme, so it cannot match an Origin. Set it to a full origin, e.g. https://example.com.`);
39
+ }
40
+ return url || undefined;
41
+ }
42
+
43
+ const configuredURL = authBaseURL(vars.BETTER_AUTH_URL);
44
+
45
+ /** A provider's credentials from the environment, when both are set. */
46
+ function credentials(prefix: string) {
47
+ const clientId = vars[`${prefix}_CLIENT_ID`];
48
+ const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
49
+ return clientId && clientSecret ? { clientId, clientSecret } : undefined;
50
+ }
51
+
52
+ const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
53
+ google: credentials("GOOGLE"),
54
+ github: credentials("GITHUB"),
55
+ // Apple's client secret is a JWT you generate from your key (see the auth guide).
56
+ apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
57
+ // "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
58
+ microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
59
+ };
60
+
61
+ /** Social providers switched on in lib/auth-config.ts that also have credentials. */
62
+ const socialProviders = Object.fromEntries(
63
+ authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
64
+ );
65
+
66
+ /** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
67
+ function disabledPath(path: string): boolean {
68
+ if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
69
+ if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
70
+ if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
71
+ const { authenticator, email } = authConfig.twoFactor;
72
+ if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
73
+ if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
74
+ if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
75
+ return false;
76
+ }
77
+
78
+ /** Where a new password arrives, and what it's called in the body. */
79
+ const NEW_PASSWORD_FIELDS: Record<string, string> = {
80
+ "/sign-up/email": "password",
81
+ "/reset-password": "newPassword",
82
+ "/change-password": "newPassword",
83
+ };
84
+
85
+ /**
86
+ * The same password rules the sign-up and reset forms show as you type
87
+ * (lib/password-rules.ts), enforced here so the API agrees with the screens.
88
+ * Checked on the way in, before the password is hashed or stored.
89
+ */
90
+ async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
91
+ const field = NEW_PASSWORD_FIELDS[path];
92
+ if (!field) return;
93
+ const password = body?.[field];
94
+ if (typeof password !== "string") return;
95
+
96
+ if (password.length > PASSWORD_MAX_LENGTH) {
97
+ throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
98
+ }
99
+ const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
100
+ if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
101
+
102
+ // A password in a public breach list is already guessed, however strong it looks.
103
+ if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
104
+ throw new APIError("BAD_REQUEST", {
105
+ message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
106
+ });
107
+ }
108
+ }
109
+
110
+ export const auth = betterAuth({
111
+ appName: "__APP_NAME__",
112
+ secret: env.BETTER_AUTH_SECRET,
113
+ // Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
114
+ // this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
115
+ // deployed app never sees a trycloudflare.com host and the allowlist can't be spoofed.
116
+ baseURL: configuredURL || {
117
+ allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
118
+ },
119
+ // Sign in with Apple posts back from Apple's own origin.
120
+ trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
121
+ database: drizzleAdapter(getDb(), { provider: "sqlite", schema }),
122
+ socialProviders,
123
+ account: {
124
+ // Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
125
+ accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
126
+ },
127
+ emailAndPassword: {
128
+ enabled: true,
129
+ requireEmailVerification: authConfig.requireEmailVerification,
130
+ // PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
131
+ password: { hash: hashPassword, verify: verifyPassword },
132
+ sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
133
+ resetPasswordTokenExpiresIn: 60 * 60,
134
+ // A password reset signs every other device out.
135
+ revokeSessionsOnPasswordReset: true,
136
+ },
137
+ emailVerification: {
138
+ sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
139
+ sendOnSignUp: true,
140
+ autoSignInAfterVerification: true,
141
+ },
142
+ advanced: {
143
+ // Let emails and other deferred work finish after the response is sent.
144
+ backgroundTasks: { handler: waitUntil },
145
+ },
146
+ hooks: {
147
+ before: createAuthMiddleware(async (ctx) => {
148
+ if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
149
+ await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
150
+ }),
151
+ },
152
+ plugins: [
153
+ // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
154
+ admin({ defaultRole: "user", adminRoles: ["admin"] }),
155
+ magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
156
+ emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
157
+ twoFactor({
158
+ issuer: "__APP_NAME__",
159
+ otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
160
+ // With only email codes switched on there's no authenticator app to confirm first.
161
+ skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
162
+ // People who signed up with a social account have no password to confirm with;
163
+ // accounts that do have one must still give it.
164
+ allowPasswordless: true,
165
+ }),
166
+ passkey({ rpName: "__APP_NAME__" }),
167
+ // Must stay last: lets server actions set auth cookies.
168
+ nextCookies(),
169
+ ],
170
+ });
171
+
172
+ export type Session = typeof auth.$Infer.Session;
173
+
174
+ /** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
175
+ export function enabledSocialProviders(): SocialProvider[] {
176
+ return Object.keys(socialProviders) as SocialProvider[];
177
+ }
@@ -8,7 +8,7 @@
8
8
  // `flare diff` shows how your copy differs from the version Flare ships, and
9
9
  // `flare update` applies the parts you choose. Neither runs unless you ask.
10
10
 
11
- import { columnName, ownershipFilter, storedFields, type Policy, type Resource } from "@flaredev/core";
11
+ import { columnName, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
12
12
  import { createValidators } from "@flaredev/core";
13
13
  import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
14
14
  import type { ResourceRows, Row } from "./rows";
@@ -46,6 +46,11 @@ export const COUNT_LIMIT = 10_000;
46
46
  function toCursorValue(value: unknown): string | number | boolean | null {
47
47
  if (value instanceof Date) return value.getTime();
48
48
  if (value === null || value === undefined) return null;
49
+ // A money column is BigInt on Postgres, and Prisma hands those back as a bigint.
50
+ // JSON.stringify throws on one — "Do not know how to serialize a BigInt" — so sorting by
51
+ // a money field broke the cursor on that stack and nowhere else. Minor units fit a double
52
+ // to about ninety trillion, which is past any amount a column like this holds.
53
+ if (typeof value === "bigint") return Number(value);
49
54
  if (typeof value === "object") return String(value);
50
55
  return value as string | number | boolean;
51
56
  }
@@ -195,9 +200,48 @@ export function createResourceStore(options: ResourceStoreOptions) {
195
200
 
196
201
  const notFound = () => fail(404, `${resource.label} not found.`);
197
202
 
203
+ /**
204
+ * Money fields, and the currency each one counts in.
205
+ *
206
+ * A money column holds whole minor units, because a column of doubles drifts — sum a few
207
+ * thousand prices and the total is a penny out. Everything above this file works in the
208
+ * units people write, so the conversion happens here and nowhere else: the API sends
209
+ * 19.99, the form shows 19.99, and only the database sees 1999.
210
+ */
211
+ const moneyFields = fields.flatMap(([key, def]) =>
212
+ (def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
213
+ );
214
+
215
+ /** Amounts as the database stores them: 19.99 -> 1999. */
216
+ function toStored(values: Row): Row {
217
+ if (moneyFields.length === 0) return values;
218
+ const out = { ...values };
219
+ for (const { key, currency } of moneyFields) {
220
+ const value = out[key];
221
+ if (typeof value === "number") out[key] = toMinorUnits(value, currency);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** Amounts as callers read them: 1999 -> 19.99. */
227
+ function toAmounts(row: Row): Row {
228
+ if (moneyFields.length === 0) return row;
229
+ const out = { ...row };
230
+ for (const { key, currency } of moneyFields) {
231
+ const value = out[key];
232
+ // Prisma hands a BigInt column back as a bigint; D1 gives a number.
233
+ if (typeof value === "number") out[key] = fromMinorUnits(value, currency);
234
+ else if (typeof value === "bigint") out[key] = fromMinorUnits(Number(value), currency);
235
+ }
236
+ return out;
237
+ }
238
+
198
239
  const computed = Object.entries(resource.computed ?? {});
199
240
  /** Add the descriptor's computed values to a row on its way out. */
200
- const withComputed = (row: Record<string, unknown>): Record<string, unknown> => {
241
+ const withComputed = (stored: Record<string, unknown>): Record<string, unknown> => {
242
+ // Amounts first: a computed value like `total` is written against the units the
243
+ // descriptor talks about, so it has to see 19.99 rather than 1999.
244
+ const row = toAmounts(stored);
201
245
  if (computed.length === 0) return row;
202
246
  const result = { ...row };
203
247
  for (const [key, compute] of computed) {
@@ -265,7 +309,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
265
309
  const limit = await confine();
266
310
  if (isFailure(limit)) return limit;
267
311
  // Last, so `?filter[userId]=someone-else` cannot widen the query past the owner.
268
- const filters = limit ? { ...parsed.query.filters, [limit.field]: limit.value } : parsed.query.filters;
312
+ const parsedFilters = limit ? { ...parsed.query.filters, [limit.field]: limit.value } : parsed.query.filters;
313
+ // `?filter[price]=19.99` is written in the units the caller knows; the column counts
314
+ // cents, so it is converted here like any other amount crossing this boundary.
315
+ const filters = toStored(parsedFilters) as typeof parsedFilters;
269
316
 
270
317
  /**
271
318
  * Whether a sort column holds a date, so the adapter can shape the cursor for it.
@@ -310,8 +357,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
310
357
  const exactTotal = counting <= COUNT_LIMIT;
311
358
  const total = exactTotal ? counting : COUNT_LIMIT;
312
359
 
313
- const first = page_[0];
314
- const last = page_[page_.length - 1];
360
+ // Cursors are built from the rows as the database gave them, not from `page_`.
361
+ // A cursor is compared against the column on the next request, so sorting by a money
362
+ // field has to carry 1999 — the stored value — and not the 19.99 callers see. Taking
363
+ // it from the converted rows paged by a number the column does not contain, which
364
+ // silently returned the same page forever.
365
+ const stored = hasMore ? found.slice(0, perPage) : found;
366
+ if (backwards) stored.reverse();
367
+
368
+ const first = stored[0];
369
+ const last = stored[stored.length - 1];
315
370
  const moreAfter = backwards ? true : hasMore;
316
371
  const moreBefore = backwards ? hasMore : Boolean(cursor) || page > 1;
317
372
  const cursorFor = (row: Record<string, unknown> | undefined, direction: "after" | "before") =>
@@ -381,7 +436,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
381
436
  if (!result.success) return invalid(result.error.issues);
382
437
  const input = result.data as Record<string, unknown>;
383
438
  const now = new Date();
384
- const record = await rows.insert({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now });
439
+ const record = await rows.insert(toStored({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now }));
385
440
  const saved = withComputed(record);
386
441
  await hooks.afterCreate?.(saved, context);
387
442
  return { ok: true, data: saved };
@@ -421,7 +476,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
421
476
  const result = validators.update.safeParse(supplied);
422
477
  if (!result.success) return invalid(result.error.issues);
423
478
  const input = result.data as Record<string, unknown>;
424
- const record = await rows.update(id, { ...input, updatedAt: new Date() });
479
+ const record = await rows.update(id, toStored({ ...input, updatedAt: new Date() }));
425
480
  if (!record) return notFound();
426
481
  const saved = withComputed(record);
427
482
  await hooks.afterUpdate?.(saved, { ...context, previous: current });
@@ -450,7 +505,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
450
505
  // one of them, and a replace would quietly orphan the row it just saved.
451
506
  if (limit) values[limit.field] = limit.value;
452
507
 
453
- const record = await rows.update(id, values);
508
+ const record = await rows.update(id, toStored(values));
454
509
  return record ? { ok: true, data: withComputed(record) } : notFound();
455
510
  });
456
511
  },