From 51390e0547ccf66b834f4a7c10b6a2101b17506a Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Thu, 16 Jan 2025 21:43:29 -0800 Subject: [PATCH 01/10] Cleanup additional defunct JSON config flags follow-up to 905ba4c6cf43de5be83eca5960ff4e182a486152 --- templates/remix/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/remix/package.json b/templates/remix/package.json index 9bb27124..89a9eba4 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -7,10 +7,10 @@ "sideEffects": false, "scripts": { "build": "remix vite:build", - "deploy": "wrangler -j deploy", + "deploy": "wrangler deploy", "dev": "superflare dev", - "start": "wrangler -j dev", - "typegen": "wrangler -j types", + "start": "wrangler dev", + "typegen": "wrangler types", "typecheck": "tsc" }, "keywords": [], From c83481976125f77d0d4732f950c635ee7d3202c8 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 09:26:59 -0800 Subject: [PATCH 02/10] Apply toJSON() to relations (stop password leak) using serialize skips the potential toJSON() override used to e.g. not include User.password in the serialized form of a User when it is serialized as a relation of another model (e.g. Article in examples/remix-cms) --- packages/superflare/src/model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superflare/src/model.ts b/packages/superflare/src/model.ts index 67f645c5..f280f161 100644 --- a/packages/superflare/src/model.ts +++ b/packages/superflare/src/model.ts @@ -220,8 +220,8 @@ export class Model { const value = this.relations[key]; acc[key] = value instanceof Array - ? value.map((model) => model.serialize()) - : value.serialize(); + ? value.map((model) => model.toJSON()) + : value.toJSON(); return acc; }, {} as Record); } From 80ed4bfc80188888155f350bff5c555a5905cee9 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 09:42:06 -0800 Subject: [PATCH 03/10] Add explicit toJSON() method to queries allows for e.g.: const articles = await Article.with("user").orderBy("createdAt", "desc").toJSON(); from a loader to explicitly return the serialized version of a model to provide it to a component. this makes it so that the version of the model that is provided during SSR is exactly equivalent to the version of the model that is made available to the component when rendered on the client. otherwise, the data made available to the component during SSR when using single fetch is the actual model instance. --- packages/superflare/src/query-builder.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/superflare/src/query-builder.ts b/packages/superflare/src/query-builder.ts index 3f9b78e1..88125916 100644 --- a/packages/superflare/src/query-builder.ts +++ b/packages/superflare/src/query-builder.ts @@ -10,6 +10,7 @@ export class QueryBuilder { private $eagerLoad: string[] = []; private $limit: number | null = null; private $single: boolean = false; + private $serialize: boolean = false; private $modelClass: any; private $afterHooks: ((results: any) => void)[] = []; @@ -69,6 +70,9 @@ export class QueryBuilder { results = await this.eagerLoadRelations(results); + results = this.$serialize + ? results.map((result: typeof this.$modelClass) => result.toJSON()) + : results; let result = this.$single ? results[0] ?? null : results; this.runCallbacks(result); @@ -139,6 +143,11 @@ export class QueryBuilder { return this.limit(1); } + toJSON(): this { + this.$serialize = true; + return this; + } + async insert(attributes: Record): Promise { try { const results = await this.connection() From 3e5940367fa17df3af05e1eecc4d6b6b6063cf3e Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 09:38:30 -0800 Subject: [PATCH 04/10] Add single fetch serialization ownKeys proxy trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit single fetch uses turbo-stream to serialize data, meaning that model.toJSON() is no longer automatically invoked as was the case with the remix json() helper. turbo-stream’s encode function calls flatten, which defines a partsForObj util that gets called for POJOs and uses Object.keys(): https://github.com/jacob-ebey/turbo-stream/blob/main/src/flatten.ts#L50 customizing ownKeys() to use toJSON() to return the appropriate keys ensures that the object gets serialized by turbo-stream in the form defined by the model ( including any customizations, e.g. the default User model, which removes the password field from the result) however, calling target.toJSON() means that relation field keys are also returned, which causes the relation model name to exist on the model (though undefined). this is why we need to check in the get() proxy trap if the prop is not undefined on target, rather than just check for the existence of the prop in target. --- packages/superflare/src/model.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/superflare/src/model.ts b/packages/superflare/src/model.ts index f280f161..2e34c558 100644 --- a/packages/superflare/src/model.ts +++ b/packages/superflare/src/model.ts @@ -34,7 +34,7 @@ export class Model { return new Proxy(this, { get(target, prop) { - if (prop in target) { + if (target[prop as keyof Model] !== undefined) { return target[prop as keyof Model]; } @@ -59,6 +59,10 @@ export class Model { target.attributes[prop] = value; return true; }, + + ownKeys(target) { + return Object.keys(target.toJSON()); + }, }); } From 4e94c502ca546bcacc7cb7b7a5dd3c715ab7da87 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Thu, 16 Jan 2025 22:20:17 -0800 Subject: [PATCH 05/10] Migrate to single fetch (prep for rr v7) https://remix.run/docs/en/main/guides/single-fetch --- apps/site/app/root.tsx | 5 ++--- apps/site/app/routes/_index.tsx | 3 +-- apps/site/vite.config.ts | 8 ++++++++ examples/remix-cms/app/routes/admin.tsx | 7 ++----- .../app/routes/admin/articles.$slug.preview.tsx | 4 ++-- .../remix-cms/app/routes/admin/articles.$slug.tsx | 4 ++-- examples/remix-cms/app/routes/admin/articles.tsx | 3 +-- .../app/routes/admin/components/article-form.tsx | 11 +++-------- examples/remix-cms/app/routes/admin/upload.$.ts | 6 ++---- examples/remix-cms/app/routes/auth/login.tsx | 6 +++--- examples/remix-cms/app/routes/auth/register.tsx | 6 +++--- examples/remix-cms/vite.config.ts | 8 ++++++++ packages/superflare/docs/database/relationships.md | 3 +-- packages/superflare/docs/security/authentication.md | 2 +- packages/superflare/docs/sessions.md | 6 +++--- templates/remix/app/routes/_auth.login.tsx | 6 +++--- templates/remix/app/routes/_auth.register.tsx | 6 +++--- templates/remix/app/routes/dashboard.tsx | 6 +++--- templates/remix/vite.config.ts | 8 ++++++++ 19 files changed, 59 insertions(+), 49 deletions(-) diff --git a/apps/site/app/root.tsx b/apps/site/app/root.tsx index 31dfb615..355273a1 100644 --- a/apps/site/app/root.tsx +++ b/apps/site/app/root.tsx @@ -1,5 +1,4 @@ import { - json, type LinksFunction, type MetaFunction, type LoaderFunctionArgs, @@ -46,13 +45,13 @@ export const links: LinksFunction = () => [ ]; export async function loader({ context: { cloudflare } }: LoaderFunctionArgs) { - return json({ + return { ENV: { DOCSEARCH_APP_ID: cloudflare.env.DOCSEARCH_APP_ID, DOCSEARCH_API_KEY: cloudflare.env.DOCSEARCH_API_KEY, DOCSEARCH_INDEX_NAME: cloudflare.env.DOCSEARCH_INDEX_NAME, }, - }); + }; } const themeScript = ` diff --git a/apps/site/app/routes/_index.tsx b/apps/site/app/routes/_index.tsx index 6be01456..a8f01ebf 100644 --- a/apps/site/app/routes/_index.tsx +++ b/apps/site/app/routes/_index.tsx @@ -1,5 +1,4 @@ import { - json, type LoaderFunctionArgs, type MetaFunction, } from "@remix-run/cloudflare"; @@ -34,7 +33,7 @@ export async function loader({ const { content, title, tableOfContents, description } = parseMarkdoc(markdown); - return json({ content, title, tableOfContents, manifest, description }); + return { content, title, tableOfContents, manifest, description }; } export const meta: MetaFunction = ({ data }) => [ diff --git a/apps/site/vite.config.ts b/apps/site/vite.config.ts index eaa5f35a..2d815fce 100644 --- a/apps/site/vite.config.ts +++ b/apps/site/vite.config.ts @@ -5,6 +5,13 @@ import { } from "@remix-run/dev"; import tsconfigPaths from "vite-tsconfig-paths"; +declare module "@remix-run/cloudflare" { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Future { + v3_singleFetch: true; + } +} + export default defineConfig({ plugins: [ cloudflareDevProxyVitePlugin(), @@ -13,6 +20,7 @@ export default defineConfig({ v3_fetcherPersist: true, v3_lazyRouteDiscovery: true, v3_relativeSplatPath: true, + v3_singleFetch: true, v3_throwAbortReason: true, }, }), diff --git a/examples/remix-cms/app/routes/admin.tsx b/examples/remix-cms/app/routes/admin.tsx index 593cf6d8..bfc68ffe 100644 --- a/examples/remix-cms/app/routes/admin.tsx +++ b/examples/remix-cms/app/routes/admin.tsx @@ -14,7 +14,7 @@ import { import clsx from "clsx"; import { Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; import { Toast } from "~/components/Toast"; -import { json, type LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; import { User } from "~/models/User"; const navigation = [ @@ -33,10 +33,7 @@ export async function loader({ const user = await auth.user(User); - return json({ - flash, - user, - }); + return { flash, user }; } export default function AdminLayout() { diff --git a/examples/remix-cms/app/routes/admin/articles.$slug.preview.tsx b/examples/remix-cms/app/routes/admin/articles.$slug.preview.tsx index b4edd183..8570d78d 100644 --- a/examples/remix-cms/app/routes/admin/articles.$slug.preview.tsx +++ b/examples/remix-cms/app/routes/admin/articles.$slug.preview.tsx @@ -19,10 +19,10 @@ export async function loader({ params }: LoaderFunctionArgs) { throw new Response("Not found", { status: 404 }); } - return json({ + return { article, html: await convertToHtml(article.content ?? ""), - }); + }; } export default function NewArticle() { diff --git a/examples/remix-cms/app/routes/admin/articles.$slug.tsx b/examples/remix-cms/app/routes/admin/articles.$slug.tsx index f7732034..b9d4e2a2 100644 --- a/examples/remix-cms/app/routes/admin/articles.$slug.tsx +++ b/examples/remix-cms/app/routes/admin/articles.$slug.tsx @@ -1,5 +1,5 @@ import { EyeIcon } from "@heroicons/react/24/outline"; -import { json, type LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { type LoaderFunctionArgs } from "@remix-run/cloudflare"; import { useLoaderData, useRevalidator } from "@remix-run/react"; import invariant from "tiny-invariant"; import { Button, SecondaryButton } from "~/components/admin/Button"; @@ -24,7 +24,7 @@ export async function loader({ params }: LoaderFunctionArgs) { SayHelloJob.dispatch(article); - return json({ article }); + return { article }; } export default function NewArticle() { diff --git a/examples/remix-cms/app/routes/admin/articles.tsx b/examples/remix-cms/app/routes/admin/articles.tsx index 91c6ae79..75dd6515 100644 --- a/examples/remix-cms/app/routes/admin/articles.tsx +++ b/examples/remix-cms/app/routes/admin/articles.tsx @@ -1,4 +1,3 @@ -import { json } from "@remix-run/cloudflare"; import { Link, useLoaderData } from "@remix-run/react"; import { Button } from "~/components/admin/Button"; import { Page } from "~/components/admin/Page"; @@ -8,7 +7,7 @@ import { useChannel } from "~/utils/use-channel"; export async function loader() { const articles = await Article.with("user").orderBy("createdAt", "desc"); - return json({ articles }); + return { articles }; } export default function Articles() { diff --git a/examples/remix-cms/app/routes/admin/components/article-form.tsx b/examples/remix-cms/app/routes/admin/components/article-form.tsx index 19fde31d..879bfd55 100644 --- a/examples/remix-cms/app/routes/admin/components/article-form.tsx +++ b/examples/remix-cms/app/routes/admin/components/article-form.tsx @@ -1,9 +1,4 @@ -import { - json, - redirect, - type SerializeFrom, - type ActionFunctionArgs, -} from "@remix-run/cloudflare"; +import { redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { Form, useActionData } from "@remix-run/react"; import { Article } from "~/models/Article"; import invariant from "tiny-invariant"; @@ -29,7 +24,7 @@ const enum Intent { Update = "update", } -const badResponse = (data: ActionData) => json(data, { status: 422 }); +const badResponse = (data: ActionData) => Response.json(data, { status: 422 }); export async function action({ request, @@ -110,7 +105,7 @@ export function ArticleForm({ article, id, }: { - article?: SerializeFrom
; + article?: Article; id?: string; }) { const actionData = useActionData(); diff --git a/examples/remix-cms/app/routes/admin/upload.$.ts b/examples/remix-cms/app/routes/admin/upload.$.ts index 66a572e1..60994ba7 100644 --- a/examples/remix-cms/app/routes/admin/upload.$.ts +++ b/examples/remix-cms/app/routes/admin/upload.$.ts @@ -1,4 +1,4 @@ -import { json, type ActionFunctionArgs } from "@remix-run/cloudflare"; +import { type ActionFunctionArgs } from "@remix-run/cloudflare"; import { parseMultipartFormData, storage } from "superflare"; export async function action({ request }: ActionFunctionArgs) { @@ -13,7 +13,5 @@ export async function action({ request }: ActionFunctionArgs) { } ); - return json({ - url: storage().url(formData.get("file") as string), - }); + return { url: storage().url(formData.get("file") as string) }; } diff --git a/examples/remix-cms/app/routes/auth/login.tsx b/examples/remix-cms/app/routes/auth/login.tsx index bb3b5124..397c45e8 100644 --- a/examples/remix-cms/app/routes/auth/login.tsx +++ b/examples/remix-cms/app/routes/auth/login.tsx @@ -1,5 +1,5 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { Form, Link, useActionData } from "@remix-run/react"; -import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { Button } from "~/components/admin/Button"; import { FormField } from "~/components/Form"; import { User } from "~/models/User"; @@ -26,11 +26,11 @@ export async function action({ return redirect("/admin"); } - return json({ error: "Invalid credentials" }, { status: 400 }); + return Response.json({ error: "Invalid credentials" }, { status: 400 }); } export default function Login() { - const actionData = useActionData(); + const actionData = useActionData<{ error: string }>(); return ( <> diff --git a/examples/remix-cms/app/routes/auth/register.tsx b/examples/remix-cms/app/routes/auth/register.tsx index 3ccc8ebc..341b8a79 100644 --- a/examples/remix-cms/app/routes/auth/register.tsx +++ b/examples/remix-cms/app/routes/auth/register.tsx @@ -1,5 +1,5 @@ import { Form, Link, useActionData } from "@remix-run/react"; -import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; +import { redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { Button } from "~/components/admin/Button"; import { FormField } from "~/components/Form"; import { User } from "~/models/User"; @@ -19,7 +19,7 @@ export async function action({ const name = formData.get("name") as string; if (await User.where("email", email).count()) { - return json({ error: "Email already exists" }, { status: 400 }); + return Response.json({ error: "Email already exists" }, { status: 400 }); } const user = await User.create({ @@ -34,7 +34,7 @@ export async function action({ } export default function Register() { - const actionData = useActionData(); + const actionData = useActionData<{ error: string }>(); return (
diff --git a/examples/remix-cms/vite.config.ts b/examples/remix-cms/vite.config.ts index eee688b5..a51c140d 100644 --- a/examples/remix-cms/vite.config.ts +++ b/examples/remix-cms/vite.config.ts @@ -4,6 +4,13 @@ import { createRoutesFromFolders } from "@remix-run/v1-route-convention"; import { superflareDevProxyVitePlugin } from "@superflare/remix/dev"; import tsconfigPaths from "vite-tsconfig-paths"; +declare module "@remix-run/cloudflare" { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Future { + v3_singleFetch: true; + } +} + export default defineConfig({ plugins: [ superflareDevProxyVitePlugin(), @@ -12,6 +19,7 @@ export default defineConfig({ v3_fetcherPersist: true, v3_lazyRouteDiscovery: true, v3_relativeSplatPath: true, + v3_singleFetch: true, v3_throwAbortReason: true, }, // Tell Remix to ignore everything in the routes directory. diff --git a/packages/superflare/docs/database/relationships.md b/packages/superflare/docs/database/relationships.md index 768ee497..a983b1b7 100644 --- a/packages/superflare/docs/database/relationships.md +++ b/packages/superflare/docs/database/relationships.md @@ -202,7 +202,6 @@ If you don't eager load the related models, the related data will not be availab ```tsx import { User } from "~/models/User"; -import { json } from "@remix-run/cloudflare"; export async function loader() { // ❌ The profile relation will not be loaded @@ -211,7 +210,7 @@ export async function loader() { // ✅ This will load the profile relation for the view const user = await User.with("profile").find(1); - return json({ user }); + return { user }; } export default function UserView() { diff --git a/packages/superflare/docs/security/authentication.md b/packages/superflare/docs/security/authentication.md index 5a9f72cd..ab9d38cc 100644 --- a/packages/superflare/docs/security/authentication.md +++ b/packages/superflare/docs/security/authentication.md @@ -43,7 +43,7 @@ export async function loader({ context: { auth } }: LoaderFunctionArgs) { const user = await auth.user(User); // If the user is logged in, show them the secret page - return json({ message: `You're logged in, ${user.name}!` }); + return { message: `You're logged in, ${user.name}!` }; } ``` diff --git a/packages/superflare/docs/sessions.md b/packages/superflare/docs/sessions.md index 0fa43068..4eb1fa5f 100644 --- a/packages/superflare/docs/sessions.md +++ b/packages/superflare/docs/sessions.md @@ -27,7 +27,7 @@ export async function action({ context: { session } }) { export async function loader({ context: { session } }) { const theme = session.get("theme"); - return json({ theme }); + return { theme }; } ``` @@ -41,7 +41,7 @@ To do this, you can use the `flash` method: export async function loader({ context: { session } }) { session.flash("success", "Your form was submitted successfully!"); - return json({ success: true }); + return { success: true }; } ``` @@ -51,7 +51,7 @@ Then, you can read the flash message in your action using the `getFlash` method: export async function action({ context: { session } }) { const success = session.getFlash("success"); - return json({ success }); + return { success }; } ``` diff --git a/templates/remix/app/routes/_auth.login.tsx b/templates/remix/app/routes/_auth.login.tsx index c4a9b8b3..1b8161c6 100644 --- a/templates/remix/app/routes/_auth.login.tsx +++ b/templates/remix/app/routes/_auth.login.tsx @@ -1,5 +1,5 @@ import { Form, Link, useActionData } from "@remix-run/react"; -import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; +import { redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { User } from "~/models/User"; export async function action({ @@ -18,11 +18,11 @@ export async function action({ return redirect("/dashboard"); } - return json({ error: "Invalid credentials" }, { status: 400 }); + return Response.json({ error: "Invalid credentials" }, { status: 400 }); } export default function Login() { - const actionData = useActionData(); + const actionData = useActionData<{ error: string }>(); return ( diff --git a/templates/remix/app/routes/_auth.register.tsx b/templates/remix/app/routes/_auth.register.tsx index 8955e03c..a17ead1b 100644 --- a/templates/remix/app/routes/_auth.register.tsx +++ b/templates/remix/app/routes/_auth.register.tsx @@ -1,5 +1,5 @@ import { Form, Link, useActionData } from "@remix-run/react"; -import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; +import { redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; import { User } from "~/models/User"; import { hash } from "superflare"; @@ -16,7 +16,7 @@ export async function action({ const password = formData.get("password") as string; if (await User.where("email", email).count()) { - return json({ error: "Email already exists" }, { status: 400 }); + return Response.json({ error: "Email already exists" }, { status: 400 }); } const user = await User.create({ @@ -30,7 +30,7 @@ export async function action({ } export default function Register() { - const actionData = useActionData(); + const actionData = useActionData<{ error: string }>(); return ( diff --git a/templates/remix/app/routes/dashboard.tsx b/templates/remix/app/routes/dashboard.tsx index 24ad1538..0a383815 100644 --- a/templates/remix/app/routes/dashboard.tsx +++ b/templates/remix/app/routes/dashboard.tsx @@ -1,4 +1,4 @@ -import { type LoaderFunctionArgs, redirect, json } from "@remix-run/cloudflare"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; import { useLoaderData } from "@remix-run/react"; import { User } from "~/models/User"; @@ -7,9 +7,9 @@ export async function loader({ context: { auth } }: LoaderFunctionArgs) { return redirect("/login"); } - return json({ + return { user: (await auth.user(User)) as User, - }); + }; } export default function Dashboard() { diff --git a/templates/remix/vite.config.ts b/templates/remix/vite.config.ts index 9d2c63ec..2bcb6055 100644 --- a/templates/remix/vite.config.ts +++ b/templates/remix/vite.config.ts @@ -3,6 +3,13 @@ import { vitePlugin as remix } from "@remix-run/dev"; import { superflareDevProxyVitePlugin } from "@superflare/remix/dev"; import tsconfigPaths from "vite-tsconfig-paths"; +declare module "@remix-run/cloudflare" { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Future { + v3_singleFetch: true; + } +} + export default defineConfig({ plugins: [ superflareDevProxyVitePlugin(), @@ -11,6 +18,7 @@ export default defineConfig({ v3_fetcherPersist: true, v3_lazyRouteDiscovery: true, v3_relativeSplatPath: true, + v3_singleFetch: true, v3_throwAbortReason: true, }, }), From b63ea4fb7f314691dec0c08a5fcf563a7aa93149 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 16:30:05 -0800 Subject: [PATCH 06/10] Refactor Model+QueryBuilder types for single fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • add an IsSingle type argument to QueryBuilder to track if first() has been invoked • add the toJSON method to QueryBuilder • update return types of BaseModel query methods to return a QueryBuilder • update QueryBuilder promise methods (then/catch) to resolve query results based on if IsSingle (i.e. if first() has been invoked) or not --- packages/superflare/index.types.ts | 67 +++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/superflare/index.types.ts b/packages/superflare/index.types.ts index 88720139..171d38b0 100644 --- a/packages/superflare/index.types.ts +++ b/packages/superflare/index.types.ts @@ -69,18 +69,12 @@ export interface HasMany[]> * Shape of the model constructor (static properties). */ export interface BaseModel { - find(this: T, ids: number[]): Promise[]>; + find(this: T, ids: number[]): QueryBuilder; find( this: T, id: number - ): Promise>; - first(this: T): Promise>; - orderBy( - this: T, - field: string, - direction?: "asc" | "desc" - ): QueryBuilder; - all(this: T): Promise[]>; + ): QueryBuilder, true>; + all(this: T): QueryBuilder; where( this: T, field: string, @@ -101,12 +95,21 @@ export interface BaseModel { this: T, relationName: string | string[] ): QueryBuilder; + orderBy( + this: T, + field: string, + direction?: "asc" | "desc" + ): QueryBuilder; + query(this: T): QueryBuilder; + // Changes return type to single instance: + first(this: T): QueryBuilder, true>; + count(this: T): Promise; + create( this: T, attributes: any ): Promise>; - count(this: T): Promise; - query(this: T): QueryBuilder; + register(model: any): void; tableName: string; @@ -115,20 +118,46 @@ export interface BaseModel { new (attributes?: any): ModelInstance; } -interface QueryBuilder> { - count(this: T): Promise; - find(this: T, id: number): Promise; - find(this: T, ids: number[]): Promise; +// Helper type to extract the JSON return type from a model instance +type JSONReturnType = I extends ModelInstance + ? ReturnType + : never; + +// Helper type to determine if the query will return a single item or array +type QueryResult = IsSingle extends true ? I : I[]; + +interface QueryBuilder< + M extends BaseModel, + I = InstanceType, + IsSingle extends boolean = false +> { + find(this: T, id: number): QueryBuilder; + find(this: T, ids: number[]): this; where(this: T, field: string, value: any): this; where(this: T, field: string, operator: string, value?: any): this; whereIn(this: T, field: string, values: (string | number)[]): this; with(this: T, relationName: string | string[]): this; limit(this: T, limit: number): this; - get(): Promise; - first(): Promise; orderBy(this: T, field: string, direction?: "asc" | "desc"): this; - then(onfulfilled?: (value: R[]) => R[] | PromiseLike): Promise; - catch(onrejected?: (reason: any) => any): Promise; + // Changes return type to single instance: + first(): QueryBuilder; + + // Promise-returning methods with specific return types + count(this: T): Promise; + get(): Promise>; + toJSON(): Promise< + IsSingle extends true ? JSONReturnType : JSONReturnType[] + >; + + // Promise compatibility + then>( + onfulfilled?: ( + value: QueryResult + ) => Result | PromiseLike + ): Promise; + catch>( + onrejected?: ((reason: any) => Result | PromiseLike) | null + ): Promise; } declare const Model: BaseModel; From 185c6cc3611a8badfb0de977235376e6e7281ded Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 16:38:21 -0800 Subject: [PATCH 07/10] =?UTF-8?q?Remove=20relation=20promise=20union=20?= =?UTF-8?q?=E2=86=92=20improve=20Model=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this allows proper type checking of resolved instances of the model when using single fetch --- examples/remix-cms/app/models/Article.ts | 2 +- examples/remix-cms/app/models/User.ts | 1 + packages/superflare/docs/database/relationships.md | 12 ++++++------ packages/superflare/tests/model/belongs-to.test.ts | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/remix-cms/app/models/Article.ts b/examples/remix-cms/app/models/Article.ts index 943c80ed..74fdc7fa 100644 --- a/examples/remix-cms/app/models/Article.ts +++ b/examples/remix-cms/app/models/Article.ts @@ -2,7 +2,7 @@ import { Model } from "superflare"; import { User } from "./User"; export class Article extends Model { - user!: User | Promise; + user!: User; $user() { return this.belongsTo(User); } diff --git a/examples/remix-cms/app/models/User.ts b/examples/remix-cms/app/models/User.ts index 95a280e9..859ec91a 100644 --- a/examples/remix-cms/app/models/User.ts +++ b/examples/remix-cms/app/models/User.ts @@ -6,6 +6,7 @@ export class User extends Model { return rest; } } + Model.register(User); export interface User extends UserRow {} diff --git a/packages/superflare/docs/database/relationships.md b/packages/superflare/docs/database/relationships.md index a983b1b7..a3290f1e 100644 --- a/packages/superflare/docs/database/relationships.md +++ b/packages/superflare/docs/database/relationships.md @@ -18,7 +18,7 @@ import { Model } from "superflare"; import { Profile } from "./Profile"; export class User extends Model { - profile!: Profile | Promise; + profile!: Profile; $profile() { return this.hasOne(Profile); } @@ -46,7 +46,7 @@ To define the inverse relationship, use the `belongsTo` method: import { Model } from "superflare"; export class Profile extends Model { - user!: User | Promise; + user!: User; $user() { return this.belongsTo(User); } @@ -71,7 +71,7 @@ To define a one-to-many relationship, use the `hasMany` method: import { Model } from "superflare"; export class User extends Model { - posts!: Post[] | Promise; + posts!: Post[]; $posts() { return this.hasMany(Post); } @@ -96,7 +96,7 @@ To define the inverse relationship, use the `belongsTo` method: import { Model } from "superflare"; export class Post extends Model { - user!: User | Promise; + user!: User; $user() { return this.belongsTo(User); } @@ -144,7 +144,7 @@ To define a many-to-many relationship, use the `belongsToMany` method: import { Model } from "superflare"; export class Post extends Model { - tags!: Tag[] | Promise; + tags!: Tag[]; $tags() { return this.belongsToMany(Tag); } @@ -171,7 +171,7 @@ To define the inverse relationship, use the `belongsToMany` method again: import { Model } from "superflare"; export class Tag extends Model { - posts!: Post[] | Promise; + posts!: Post[]; $posts() { return this.belongsToMany(Post); } diff --git a/packages/superflare/tests/model/belongs-to.test.ts b/packages/superflare/tests/model/belongs-to.test.ts index 864992d4..aad56d9a 100644 --- a/packages/superflare/tests/model/belongs-to.test.ts +++ b/packages/superflare/tests/model/belongs-to.test.ts @@ -14,7 +14,7 @@ class Post extends ModelConstructor { updatedAt!: string; userId!: number; - user!: User | Promise; + user!: User; $user() { return this.belongsTo(User); } From 0007acbdce6185c43c34b0e6bcaaecd288da4a99 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Fri, 17 Jan 2025 13:42:32 -0800 Subject: [PATCH 08/10] Remove defunct build.d.ts files, add GH env var the placeholder GITHUB_TOKEN in .dev.vars ensures that wrangler includes it in its typegen (worker-configuration.d.ts) --- apps/site/.env.example | 1 + apps/site/types/build.d.ts | 8 -------- apps/site/worker-configuration.d.ts | 1 + examples/remix-cms/types/build.d.ts | 8 -------- 4 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 apps/site/types/build.d.ts delete mode 100644 examples/remix-cms/types/build.d.ts diff --git a/apps/site/.env.example b/apps/site/.env.example index 71272a7e..3d7dacfa 100644 --- a/apps/site/.env.example +++ b/apps/site/.env.example @@ -1,3 +1,4 @@ DOCSEARCH_APP_ID=RETR9S9VHS DOCSEARCH_API_KEY=326c1723a310dfe29004b47608709907 DOCSEARCH_INDEX_NAME=tailwindui-protocol +GITHUB_TOKEN=foo diff --git a/apps/site/types/build.d.ts b/apps/site/types/build.d.ts deleted file mode 100644 index 7744b692..00000000 --- a/apps/site/types/build.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type ServerBuild } from "@remix-run/cloudflare"; - -export const assets: ServerBuild["assets"]; -export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; -export const entry: ServerBuild["entry"]; -export const future: ServerBuild["future"]; -export const publicPath: ServerBuild["publicPath"]; -export const routes: ServerBuild["routes"]; diff --git a/apps/site/worker-configuration.d.ts b/apps/site/worker-configuration.d.ts index dbd8675b..ec78ae4b 100644 --- a/apps/site/worker-configuration.d.ts +++ b/apps/site/worker-configuration.d.ts @@ -4,4 +4,5 @@ interface Env { DOCSEARCH_APP_ID: string; DOCSEARCH_API_KEY: string; DOCSEARCH_INDEX_NAME: string; + GITHUB_TOKEN: string; } diff --git a/examples/remix-cms/types/build.d.ts b/examples/remix-cms/types/build.d.ts deleted file mode 100644 index 7744b692..00000000 --- a/examples/remix-cms/types/build.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type ServerBuild } from "@remix-run/cloudflare"; - -export const assets: ServerBuild["assets"]; -export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; -export const entry: ServerBuild["entry"]; -export const future: ServerBuild["future"]; -export const publicPath: ServerBuild["publicPath"]; -export const routes: ServerBuild["routes"]; From 8665ffb7ac91362b0dbb4b92b4de9fd9de568b0d Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 21 Jan 2025 16:14:26 -0800 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20Fix=20typos/grammar=20in?= =?UTF-8?q?=20docs=20and=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/superflare/docs/database/getting-started.md | 2 +- packages/superflare/docs/database/relationships.md | 2 +- packages/superflare/src/model.ts | 2 +- packages/superflare/tests/d1-types.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/superflare/docs/database/getting-started.md b/packages/superflare/docs/database/getting-started.md index 1d75a9bf..68f4d946 100644 --- a/packages/superflare/docs/database/getting-started.md +++ b/packages/superflare/docs/database/getting-started.md @@ -58,7 +58,7 @@ However, we cannot simply send the entire data model over the wire from the serv Instead, modern frameworks will serialize the output sent (from "loaders" in Remix, or passed as props from server components to client components in Next.js). -Superflare makes it obvious how to serialize your models as JSON by providing the standard `toJSON()` method by on new models. +Superflare makes it obvious how to serialize your models as JSON by providing the standard `toJSON()` method on new models. By default, this method will return all of the model's attributes, in addition to any relations that are loaded on the instance either manually or by using eager-loading. diff --git a/packages/superflare/docs/database/relationships.md b/packages/superflare/docs/database/relationships.md index a3290f1e..18335b4b 100644 --- a/packages/superflare/docs/database/relationships.md +++ b/packages/superflare/docs/database/relationships.md @@ -163,7 +163,7 @@ for (const tag of tags) { } ``` -When invoking the `$tags()` method or awaiting the `tags` property, Superflare will check an intermediate table, often referred to as a "join table," for rows which have a `postId` that matches the `id` of the `Post` model. It will return all `Tag` models which have a `id` that matches the `tagId` of the join table rows. +When invoking the `$tags()` method or awaiting the `tags` property, Superflare will check an intermediate table, often referred to as a "join table", for rows which have a `postId` that matches the `id` of the `Post` model. It will return all `Tag` models which have a `id` that matches the `tagId` of the join table rows. To define the inverse relationship, use the `belongsToMany` method again: diff --git a/packages/superflare/src/model.ts b/packages/superflare/src/model.ts index 2e34c558..de94d278 100644 --- a/packages/superflare/src/model.ts +++ b/packages/superflare/src/model.ts @@ -46,7 +46,7 @@ export class Model { return target.attributes[prop]; } - // If trying to access a relation property, and it hasn't be set yet, call the relation function. + // If trying to access a relation property that hasn't been set yet, call the relation function. if (typeof prop === "string" && target[`$${prop}` as keyof Model]) { return target[`$${prop}` as keyof Model](); } diff --git a/packages/superflare/tests/d1-types.test.ts b/packages/superflare/tests/d1-types.test.ts index 725c37d5..ccee50e6 100644 --- a/packages/superflare/tests/d1-types.test.ts +++ b/packages/superflare/tests/d1-types.test.ts @@ -146,7 +146,7 @@ describe("syncSuperflareTypes", () => { const types = await generateTypesFromSqlite(db); const results = syncSuperflareTypes(tmpDir, modelsDir, types); - // Expect `user.ts` to not exist + // Expect `User.ts` to not exist expect(fs.existsSync(path.join(tmpDir, "User.ts"))).toBe(false); expect(fs.readFileSync(path.join(modelsDir, "Post.ts"), "utf8")).toBe( @@ -173,7 +173,7 @@ describe("syncSuperflareTypes", () => { createIfNotFound: true, }); - // Expect `user.ts` to have been created + // Expect `User.ts` to have been created expect(fs.existsSync(path.join(modelsDir, "User.ts"))).toBe(true); expect(fs.readFileSync(path.join(modelsDir, "User.ts"), "utf8")).toBe( "import { Model } from 'superflare';\n\n" + From 89878fc74012a73c34ea6ea91c3215389962adb4 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Thu, 6 Feb 2025 18:35:21 -0800 Subject: [PATCH 10/10] Update publish-snapshot.yml to get latest corepack --- .github/workflows/publish-snapshot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 2b9b15ea..fe6575e1 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -9,7 +9,7 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - run: corepack enable + - run: npm install -g corepack@latest && corepack enable - uses: actions/setup-node@v4 with: node-version: 20