@flaredev/cli 0.9.1 → 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.
- package/dist/index.js +241 -37
- package/package.json +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +8 -8
- package/templates/app/lib/auth.ts +177 -154
- package/templates/app/lib/resource/store.ts +63 -8
- package/templates/locks/package-lock.json +32 -32
- package/templates/locks/pnpm-lock.yaml +105 -105
- package/templates/locks/yarn.lock +33 -33
- package/templates/next/lib/auth.ts +25 -2
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 = (
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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
|
},
|
|
@@ -238,9 +238,9 @@
|
|
|
238
238
|
}
|
|
239
239
|
},
|
|
240
240
|
"node_modules/@cloudflare/vite-plugin": {
|
|
241
|
-
"version": "1.62.
|
|
242
|
-
"resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.62.
|
|
243
|
-
"integrity": "sha512-
|
|
241
|
+
"version": "1.62.5",
|
|
242
|
+
"resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.62.5.tgz",
|
|
243
|
+
"integrity": "sha512-qJJonL8EvKuSPfrwSMiDEPp+klxTU08pan1bgm/jc3LrYe9Hp4XUkIGAcQdgv7P9OZbe6AU+51iMUtQOt85J2w==",
|
|
244
244
|
"dev": true,
|
|
245
245
|
"license": "MIT",
|
|
246
246
|
"dependencies": {
|
|
@@ -248,7 +248,7 @@
|
|
|
248
248
|
"miniflare": "5.20261001.0-alpha",
|
|
249
249
|
"unenv": "2.0.0-rc.24",
|
|
250
250
|
"workerd": "1.20261001.1",
|
|
251
|
-
"wrangler": "4.
|
|
251
|
+
"wrangler": "4.147.0",
|
|
252
252
|
"ws": "8.21.0"
|
|
253
253
|
},
|
|
254
254
|
"bin": {
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
},
|
|
257
257
|
"peerDependencies": {
|
|
258
258
|
"vite": "^6.1.0 || ^7.0.0 || ^8.0.0",
|
|
259
|
-
"wrangler": "^4.
|
|
259
|
+
"wrangler": "^4.147.0"
|
|
260
260
|
}
|
|
261
261
|
},
|
|
262
262
|
"node_modules/@cloudflare/workerd-darwin-64": {
|
|
@@ -4915,9 +4915,9 @@
|
|
|
4915
4915
|
}
|
|
4916
4916
|
},
|
|
4917
4917
|
"node_modules/@vitejs/plugin-react": {
|
|
4918
|
-
"version": "6.1.
|
|
4919
|
-
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.
|
|
4920
|
-
"integrity": "sha512-
|
|
4918
|
+
"version": "6.1.2",
|
|
4919
|
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
|
|
4920
|
+
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
|
|
4921
4921
|
"license": "MIT",
|
|
4922
4922
|
"dependencies": {
|
|
4923
4923
|
"@rolldown/pluginutils": "^1.0.1"
|
|
@@ -4928,7 +4928,7 @@
|
|
|
4928
4928
|
"peerDependencies": {
|
|
4929
4929
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
|
4930
4930
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
4931
|
-
"oxc-transform-react": "^0.
|
|
4931
|
+
"oxc-transform-react": "^0.152.0",
|
|
4932
4932
|
"vite": "^8.0.0"
|
|
4933
4933
|
},
|
|
4934
4934
|
"peerDependenciesMeta": {
|
|
@@ -4973,9 +4973,9 @@
|
|
|
4973
4973
|
}
|
|
4974
4974
|
},
|
|
4975
4975
|
"node_modules/@vitejs/plugin-rsc/node_modules/magic-string": {
|
|
4976
|
-
"version": "1.4.
|
|
4977
|
-
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.
|
|
4978
|
-
"integrity": "sha512-
|
|
4976
|
+
"version": "1.4.3",
|
|
4977
|
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
|
4978
|
+
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
|
|
4979
4979
|
"devOptional": true,
|
|
4980
4980
|
"license": "MIT",
|
|
4981
4981
|
"dependencies": {
|
|
@@ -5158,9 +5158,9 @@
|
|
|
5158
5158
|
"peer": true
|
|
5159
5159
|
},
|
|
5160
5160
|
"node_modules/acorn": {
|
|
5161
|
-
"version": "8.
|
|
5162
|
-
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.
|
|
5163
|
-
"integrity": "sha512-
|
|
5161
|
+
"version": "8.19.0",
|
|
5162
|
+
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
|
5163
|
+
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
|
5164
5164
|
"license": "MIT",
|
|
5165
5165
|
"bin": {
|
|
5166
5166
|
"acorn": "bin/acorn"
|
|
@@ -6925,9 +6925,9 @@
|
|
|
6925
6925
|
}
|
|
6926
6926
|
},
|
|
6927
6927
|
"node_modules/lucide-react": {
|
|
6928
|
-
"version": "1.
|
|
6929
|
-
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.
|
|
6930
|
-
"integrity": "sha512-
|
|
6928
|
+
"version": "1.52.0",
|
|
6929
|
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
|
|
6930
|
+
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
|
|
6931
6931
|
"license": "ISC",
|
|
6932
6932
|
"peerDependencies": {
|
|
6933
6933
|
"@types/react": "*",
|
|
@@ -7018,9 +7018,9 @@
|
|
|
7018
7018
|
}
|
|
7019
7019
|
},
|
|
7020
7020
|
"node_modules/minimizer-webpack-plugin": {
|
|
7021
|
-
"version": "5.
|
|
7022
|
-
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.
|
|
7023
|
-
"integrity": "sha512-
|
|
7021
|
+
"version": "5.13.0",
|
|
7022
|
+
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.13.0.tgz",
|
|
7023
|
+
"integrity": "sha512-OO1NF2zHj6sf4CNv2AsUWYcS8WEwkVKQDXxxKzlOxx1Xl+WlqYtVC+oNlGNwGkLAOnpj277BSHypWwRtd9w4JQ==",
|
|
7024
7024
|
"license": "MIT",
|
|
7025
7025
|
"peer": true,
|
|
7026
7026
|
"dependencies": {
|
|
@@ -7091,9 +7091,9 @@
|
|
|
7091
7091
|
}
|
|
7092
7092
|
},
|
|
7093
7093
|
"node_modules/nanoid": {
|
|
7094
|
-
"version": "3.3.
|
|
7095
|
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
|
7096
|
-
"integrity": "sha512-
|
|
7094
|
+
"version": "3.3.20",
|
|
7095
|
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
|
7096
|
+
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
|
7097
7097
|
"funding": [
|
|
7098
7098
|
{
|
|
7099
7099
|
"type": "github",
|
|
@@ -7202,9 +7202,9 @@
|
|
|
7202
7202
|
}
|
|
7203
7203
|
},
|
|
7204
7204
|
"node_modules/postcss": {
|
|
7205
|
-
"version": "8.5.
|
|
7206
|
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
|
7207
|
-
"integrity": "sha512-
|
|
7205
|
+
"version": "8.5.29",
|
|
7206
|
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
|
7207
|
+
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
|
7208
7208
|
"funding": [
|
|
7209
7209
|
{
|
|
7210
7210
|
"type": "opencollective",
|
|
@@ -7221,9 +7221,9 @@
|
|
|
7221
7221
|
],
|
|
7222
7222
|
"license": "MIT",
|
|
7223
7223
|
"dependencies": {
|
|
7224
|
-
"nanoid": "^3.3.
|
|
7224
|
+
"nanoid": "^3.3.19",
|
|
7225
7225
|
"picocolors": "^1.1.1",
|
|
7226
|
-
"source-map-js": "^1.2.
|
|
7226
|
+
"source-map-js": "^1.2.2"
|
|
7227
7227
|
},
|
|
7228
7228
|
"engines": {
|
|
7229
7229
|
"node": "^10 || ^12 || >=14"
|
|
@@ -8610,9 +8610,9 @@
|
|
|
8610
8610
|
}
|
|
8611
8611
|
},
|
|
8612
8612
|
"node_modules/wrangler": {
|
|
8613
|
-
"version": "4.
|
|
8614
|
-
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.
|
|
8615
|
-
"integrity": "sha512-
|
|
8613
|
+
"version": "4.147.0",
|
|
8614
|
+
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.147.0.tgz",
|
|
8615
|
+
"integrity": "sha512-pQYRoiq8PTAxphaG69z8+GC1DkSGd19EDZehQ8zxjo/Ko3mRB6Qs1mTrd8ZuKAarLklIjTqr1lUdCK9r4q2hUg==",
|
|
8616
8616
|
"dev": true,
|
|
8617
8617
|
"license": "MIT OR Apache-2.0",
|
|
8618
8618
|
"dependencies": {
|