@flaredev/cli 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.js +4858 -3395
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +19 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +3 -1
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +67 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/handlers.ts +9 -2
  22. package/templates/app/lib/resource/http.ts +24 -13
  23. package/templates/app/lib/resource/store.ts +79 -27
  24. package/templates/app/lib/storage/cloudinary.ts +146 -0
  25. package/templates/app/lib/storage/uploadthing.ts +160 -0
  26. package/templates/app/lib/storage.ts +72 -6
  27. package/templates/app/lib/user-titles.ts +52 -0
  28. package/templates/app/worker/index.ts +4 -1
  29. package/templates/app/wrangler.jsonc +11 -0
  30. package/templates/locks/cloudflare/package-lock.json +571 -691
  31. package/templates/locks/cloudflare/pnpm-lock.yaml +1065 -1095
  32. package/templates/locks/cloudflare/yarn.lock +363 -381
  33. package/templates/locks/next/package-lock.json +284 -243
  34. package/templates/locks/next/pnpm-lock.yaml +898 -878
  35. package/templates/locks/next/yarn.lock +338 -295
  36. package/templates/next/README.md +35 -3
  37. package/templates/next/lib/auth.ts +65 -2
  38. package/templates/next/lib/dashboard.ts +3 -1
  39. package/templates/next/lib/invitations.ts +119 -0
  40. package/templates/next/lib/rate-limit.ts +83 -0
  41. package/templates/next/lib/resource/prisma-rows.ts +52 -9
  42. package/templates/next/lib/storage/disk.ts +128 -0
  43. package/templates/next/lib/storage/r2.ts +86 -0
  44. package/templates/next/lib/storage.ts +83 -66
  45. package/templates/next/lib/user-titles.ts +49 -0
@@ -2,15 +2,47 @@
2
2
 
3
3
  A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
4
4
 
5
+ ## Local development
6
+
5
7
  ```bash
6
8
  cp .env.example .env # DATABASE_URL and BETTER_AUTH_SECRET, at least
7
- __PM__ run db:migrate # prisma migrate dev: write and apply a migration
9
+ __PM__ run db:migrate # prisma migrate dev: write and apply a migration locally
10
+ __PM__ run db:seed # run seed files in seeds/
8
11
  __PM__ run dev # start the dev server on http://localhost:3000
9
- __PM__ run build # production build
10
- __PM__ run deploy # deploy to Vercel
11
12
  ```
12
13
 
13
14
  After `flare gen resource`, run `__PM__ run db:migrate` to turn the new models
14
15
  in `prisma/schema/resources.prisma` into a migration.
15
16
 
17
+ ## Deploying to Vercel
18
+
19
+ Vercel builds on its own machines, but **does not touch your database during deployment**. A newly provisioned Postgres database starts empty; attempting to sign up or sign in before applying migrations will fail with `500 Internal Server Error`.
20
+
21
+ Apply migrations and initial seeds once from your local machine against your production database:
22
+
23
+ ```bash
24
+ # 1. Apply existing migrations to production (runs prisma migrate deploy)
25
+ DATABASE_URL="<your POSTGRES_PRISMA_URL>" npx flare migrate
26
+
27
+ # 2. Seed initial data (if needed)
28
+ DATABASE_URL="<your POSTGRES_PRISMA_URL>" __PM__ run db:seed
29
+ ```
30
+
31
+ ### Production Database Notes:
32
+ - **`POSTGRES_PRISMA_URL` vs `DATABASE_URL`**: Vercel Postgres integration defines `POSTGRES_PRISMA_URL`. Flare and Prisma look for `DATABASE_URL`. Pass it inline as shown above rather than rewriting your local `.env`.
33
+ - **Do not run `prisma migrate dev` against production**: Cloud databases reject creating shadow databases. Use `npx flare migrate` (`prisma migrate deploy`) or `npx prisma db push`.
34
+ - **`BETTER_AUTH_URL`**: Set without a trailing slash (e.g. `https://myapp.vercel.app`) to avoid 403 origin mismatch errors.
35
+
36
+ Full guide: https://flare-docs.codetotech.com/guides/vercel-deployment/
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ | --- | --- |
42
+ | `__PM__ run dev` | Start dev server on http://localhost:3000 |
43
+ | `__PM__ run build` | Generate Prisma client and create production Next.js build |
44
+ | `__PM__ run deploy` | Deploy to Vercel (`vercel deploy --prod`) |
45
+ | `__PM__ run db:migrate` | Create and apply migrations locally (`prisma migrate dev`) |
46
+ | `__PM__ run db:seed` | Run seed scripts in `seeds/` |
47
+
16
48
  Docs: https://flare-docs.codetotech.com
@@ -1,4 +1,6 @@
1
1
  import { after } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { INVITE_COOKIE, invitationAllows, spendInvitation } from "./invitations";
2
4
  import { passkey } from "@better-auth/passkey";
3
5
  import { prismaAdapter } from "@better-auth/prisma-adapter";
4
6
  import { betterAuth } from "better-auth";
@@ -107,6 +109,48 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
107
109
  }
108
110
  }
109
111
 
112
+ /**
113
+ * Whether this account may be created at all — `authConfig.signup`.
114
+ *
115
+ * Checked where the user row is written rather than on each sign-up route, because there
116
+ * are four ways to end up with a new account (a password sign-up, a magic link, an email
117
+ * code, a social callback) and gating them one by one is how one gets missed.
118
+ *
119
+ * **The first account is always allowed.** An app nobody can sign into cannot be set up,
120
+ * and in `invite` mode there would be nobody to issue the invitation. Everybody after
121
+ * them goes through this; the first account also becomes the admin (see below).
122
+ */
123
+ async function assertSignUpAllowed(email: string): Promise<void> {
124
+ if (authConfig.signup === "open") return;
125
+ if ((await prisma.user.findFirst({ select: { id: true } })) === null) return;
126
+
127
+ if (authConfig.signup === "closed") {
128
+ throw new APIError("FORBIDDEN", { message: "This app is not accepting new accounts." });
129
+ }
130
+
131
+ // `invite`: the token was put in a cookie when the invitation link was opened.
132
+ const token = (await cookies()).get(INVITE_COOKIE)?.value;
133
+ if (!(await invitationAllows(token, email))) {
134
+ throw new APIError("FORBIDDEN", { message: "You need an invitation to create an account here." });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Spend the invitation this sign-up used, once the account exists.
140
+ *
141
+ * Separate from the check because the account does not exist yet when the check runs: an
142
+ * invitation has to survive a sign-up that fails validation, and only be used up by one
143
+ * that worked.
144
+ */
145
+ async function spendUsedInvitation(): Promise<void> {
146
+ if (authConfig.signup !== "invite") return;
147
+ const jar = await cookies();
148
+ const token = jar.get(INVITE_COOKIE)?.value;
149
+ if (!token) return;
150
+ await spendInvitation(token);
151
+ jar.delete(INVITE_COOKIE);
152
+ }
153
+
110
154
  /**
111
155
  * The first account to exist becomes the admin.
112
156
  *
@@ -183,6 +227,17 @@ export const auth = betterAuth({
183
227
  // verification email doesn't hold the sign-up request open.
184
228
  backgroundTasks: { handler: (promise: Promise<unknown>) => after(() => promise) },
185
229
  },
230
+ databaseHooks: {
231
+ user: {
232
+ create: {
233
+ // The one place every sign-up passes through, whichever route it came in on.
234
+ before: async (user: { email: string }) => {
235
+ await assertSignUpAllowed(user.email);
236
+ return { data: user };
237
+ },
238
+ },
239
+ },
240
+ },
186
241
  hooks: {
187
242
  before: createAuthMiddleware(async (ctx) => {
188
243
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
@@ -192,12 +247,20 @@ export const auth = betterAuth({
192
247
  // link, a social callback — so this asks after all of them rather than guessing which.
193
248
  after: createAuthMiddleware(async () => {
194
249
  await promoteFirstUser();
250
+ await spendUsedInvitation();
195
251
  }),
196
252
  },
197
253
  plugins: [
198
- // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
254
+ // Adds user.role (default "user") and admin APIs. The dashboard allows ADMIN_ROLES (lib/dashboard.ts).
199
255
  admin({ defaultRole: "user", adminRoles: ["admin"] }),
200
- magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
256
+ // disableSignUp follows the mode: a magic link to an address with no account would
257
+ // otherwise create one, which is a sign-up through a side door. In `invite` mode the
258
+ // link is still how an invited person signs *in* — they have an account by then.
259
+ magicLink({
260
+ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }),
261
+ expiresIn: 5 * 60,
262
+ disableSignUp: authConfig.signup !== "open",
263
+ }),
201
264
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
202
265
  twoFactor({
203
266
  issuer: "__APP_NAME__",
@@ -80,7 +80,9 @@ export function dashboardStore(name: string): ResourceStore {
80
80
  if (!store) {
81
81
  store = createResourceStore({
82
82
  resource: entry.resource,
83
- rows: prismaRows(entry.delegate, prisma),
83
+ // The descriptor goes in so the store can translate enum values both ways:
84
+ // Prisma's client speaks member names, so the column's "A+" is "A_2b" to it.
85
+ rows: prismaRows(entry.delegate, prisma, entry.resource),
84
86
  // The admin is not exempt from ownership. A policy that confines staff to their own
85
87
  // rows has to mean the same thing here as it does over the API, or the dashboard is
86
88
  // a way around it.
@@ -0,0 +1,119 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Invitations, for an app whose sign-up is `invite` (see lib/auth-config.ts) — the
12
+ * Prisma half.
13
+ *
14
+ * **They live in the `Verification` model**, which Better Auth already has and already
15
+ * uses for exactly this shape of thing: a single-use token with an expiry. Reusing it
16
+ * means invitations need no model of their own and so no migration — an app switching to
17
+ * `invite` changes one line in auth-config.ts and nothing else.
18
+ *
19
+ * Rows are keyed `invite:<hash>`. Only the hash is stored, never the token itself, for the
20
+ * same reason a password is not: a leaked database should not be a pile of working
21
+ * invitations.
22
+ */
23
+ import { prisma } from "@/lib/db";
24
+
25
+ /** How long an invitation is good for, unless you say otherwise. */
26
+ export const DEFAULT_INVITE_DAYS = 7;
27
+
28
+ /** The cookie an opened invitation link leaves behind, for the sign-up that follows. */
29
+ export const INVITE_COOKIE = "flare_invite";
30
+
31
+ /** How long that cookie lives: long enough to fill in a form, not long enough to forget. */
32
+ export const INVITE_COOKIE_MAX_AGE = 60 * 60;
33
+
34
+ const KEY_PREFIX = "invite:";
35
+
36
+ /** WebCrypto, so this file does not depend on which runtime it is loaded in. */
37
+ const randomToken = () => {
38
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
39
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ };
41
+
42
+ /** Only the hash is stored, so a copy of the table is not a pile of working invitations. */
43
+ async function hash(token: string): Promise<string> {
44
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
45
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
46
+ }
47
+
48
+ const key = async (token: string) => `${KEY_PREFIX}${await hash(token)}`;
49
+
50
+ /** What an invitation is for: one address, or anybody who has the link. */
51
+ export type InviteFor = { email: string } | { anyone: true };
52
+
53
+ /** An invitation. The token is returned once and never stored. */
54
+ export interface CreatedInvitation {
55
+ token: string;
56
+ expiresAt: Date;
57
+ /** Who it is for, or null when anybody with the link may use it. */
58
+ email: string | null;
59
+ }
60
+
61
+ /** Create one. The returned token is the only copy. */
62
+ export async function createInvitation(options: { for: InviteFor; days?: number }): Promise<CreatedInvitation> {
63
+ const token = randomToken();
64
+ const email = "email" in options.for ? options.for.email.trim().toLowerCase() : null;
65
+ const expiresAt = new Date(Date.now() + (options.days ?? DEFAULT_INVITE_DAYS) * 86_400_000);
66
+ await prisma.verification.create({
67
+ // `*` rather than an empty string, so "for anybody" is a value and not a mistake.
68
+ data: { identifier: await key(token), value: email ?? "*", expiresAt },
69
+ });
70
+ return { token, expiresAt, email };
71
+ }
72
+
73
+ /** The link to send somebody, given the app's own origin. */
74
+ export const invitationLink = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/invite/${token}`;
75
+
76
+ /**
77
+ * Whether this token is a live invitation for this email address.
78
+ *
79
+ * Checked, not spent: the account does not exist yet when this is asked, and an
80
+ * invitation should survive a sign-up that fails validation.
81
+ */
82
+ export async function invitationAllows(token: string | undefined, email: string): Promise<boolean> {
83
+ if (!token) return false;
84
+ const row = await prisma.verification.findFirst({ where: { identifier: await key(token) } });
85
+ if (!row) return false;
86
+ if (row.expiresAt.getTime() <= Date.now()) return false;
87
+ // An invitation addressed to somebody is for them alone; `*` is for whoever has the link.
88
+ return row.value === "*" || row.value === email.trim().toLowerCase();
89
+ }
90
+
91
+ /** Spend it, so the link cannot be used again. Called once the account exists. */
92
+ export async function spendInvitation(token: string | undefined): Promise<void> {
93
+ if (!token) return;
94
+ await prisma.verification.deleteMany({ where: { identifier: await key(token) } });
95
+ }
96
+
97
+ /** Invitations an admin can see: live ones, newest first. Never the tokens. */
98
+ export async function listInvitations(): Promise<{ id: string; email: string | null; expiresAt: Date; createdAt: Date }[]> {
99
+ const rows = await prisma.verification.findMany({
100
+ where: { identifier: { startsWith: KEY_PREFIX }, expiresAt: { gt: new Date() } },
101
+ orderBy: { createdAt: "desc" },
102
+ });
103
+ return rows.map((row) => ({ id: row.id, email: row.value === "*" ? null : row.value, expiresAt: row.expiresAt, createdAt: row.createdAt }));
104
+ }
105
+
106
+ /** Revoke one by its row id, so a link sent to the wrong address stops working. */
107
+ export async function revokeInvitation(id: string): Promise<void> {
108
+ await prisma.verification.deleteMany({ where: { id } });
109
+ }
110
+
111
+ /**
112
+ * Drop invitations that have expired.
113
+ *
114
+ * Only rows under the `invite:` prefix — the rest of this table is Better Auth's, and
115
+ * deleting its tokens would sign people out and break magic links.
116
+ */
117
+ export async function pruneInvitations(): Promise<void> {
118
+ await prisma.verification.deleteMany({ where: { identifier: { startsWith: KEY_PREFIX }, expiresAt: { lt: new Date() } } });
119
+ }
@@ -0,0 +1,83 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Where a resource's rate limits are counted — the Next.js half.
12
+ *
13
+ * A descriptor says `rateLimit: { create: "10/min" }`; the generated route asks here
14
+ * whether this call is within it. The counter is Redis: `INCR` then `EXPIRE` on the first
15
+ * call of a window, which is two round trips at most and exact across every instance —
16
+ * the reason lib/redis.ts exists.
17
+ *
18
+ * **If Redis is not configured, nothing is limited.** Say so loudly once rather than
19
+ * failing every request: a serverless function that refuses to serve is worse than one
20
+ * that logs that a limit is not being enforced, and `flare doctor` reports it as a
21
+ * problem. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
22
+ */
23
+ import { fixedWindow, rateLimitKey, type Rate, type RateLimitDecision } from "@flaredev/core";
24
+ import { redis } from "@/lib/redis";
25
+
26
+ /** Allowed, with no count kept — what an unconfigured limiter answers. */
27
+ const UNLIMITED: RateLimitDecision = { allowed: true, remaining: Number.MAX_SAFE_INTEGER, retryAfter: 0 };
28
+
29
+ let warned = false;
30
+
31
+ /**
32
+ * Count one call and say whether it was within the limit.
33
+ *
34
+ * `caller` is the signed-in account's id where there is one and the client IP otherwise,
35
+ * so a limit is per person rather than per everyone behind one connection.
36
+ */
37
+ export async function checkRateLimit(
38
+ resource: string,
39
+ action: string,
40
+ caller: string,
41
+ rate: Rate,
42
+ ): Promise<RateLimitDecision> {
43
+ // Captured in a local, because the narrowing from this check does not reach inside the
44
+ // callback below — the closure is called later, so TypeScript is right to doubt it.
45
+ const client = redis;
46
+ if (!client) {
47
+ if (!warned) {
48
+ warned = true;
49
+ console.warn(
50
+ "[flare] A resource declares a rateLimit, but Upstash Redis is not configured, so nothing is being limited. " +
51
+ "Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (flare doctor will show you).",
52
+ );
53
+ }
54
+ return UNLIMITED;
55
+ }
56
+
57
+ const key = `flare:rate:${rateLimitKey(resource, action, caller)}`;
58
+ try {
59
+ return await fixedWindow(rate, async (windowSeconds) => {
60
+ const count = await client.incr(key);
61
+ // Only the call that opened the window sets the expiry, so a busy key's window is
62
+ // not pushed forward on every hit — that would make the limit unbounded.
63
+ if (count === 1) {
64
+ await client.expire(key, windowSeconds);
65
+ return { count, ttl: windowSeconds };
66
+ }
67
+ const ttl = await client.ttl(key);
68
+ // A key with no expiry (a crash between INCR and EXPIRE) would otherwise count for
69
+ // ever; give it one and treat this window as full-length.
70
+ if (ttl < 0) {
71
+ await client.expire(key, windowSeconds);
72
+ return { count, ttl: windowSeconds };
73
+ }
74
+ return { count, ttl };
75
+ });
76
+ } catch (error) {
77
+ // A counter that is unreachable must not take the endpoint down with it. Letting the
78
+ // call through is the safer failure: the alternative is an outage in the limiter
79
+ // becoming an outage in the app.
80
+ console.error("[flare] rate limit counter failed, allowing the request:", error);
81
+ return UNLIMITED;
82
+ }
83
+ }
@@ -20,6 +20,7 @@
20
20
  * package can't depend on one. The delegate is described structurally instead, which is
21
21
  * also what makes it easy to test without a database.
22
22
  */
23
+ import { enumTranslations, storedFields, type EnumTranslation, type Resource } from "@flaredev/core";
23
24
  import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
24
25
 
25
26
  /**
@@ -79,10 +80,46 @@ function fromIndexName(name: string): string {
79
80
  return match ? match[1]! : name;
80
81
  }
81
82
 
82
- export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows {
83
+ /**
84
+ * Prisma's client speaks enum *member* names, not the values stored in the column.
85
+ *
86
+ * `A+` cannot be an identifier, so the schema declares the member `A_2b` with
87
+ * `@map("A+")`. The map applies to Postgres: the column holds `A+`, and the generated
88
+ * client still hands back `A_2b` and insists on it when writing. Left alone, the admin
89
+ * shows `A_2b`, the validators reject it because the descriptor says the value is `A+`,
90
+ * and a create fails outright.
91
+ *
92
+ * So values are translated at this boundary, and nothing above it has to know. A
93
+ * resource whose options are all identifiers already — `draft`, `published`, which is
94
+ * most of them — gets no translation table and pays nothing.
95
+ */
96
+ function translate(row: Row, enums: Record<string, EnumTranslation>, direction: "toValue" | "toMember"): Row {
97
+ let copy: Row | undefined;
98
+ for (const key of Object.keys(enums)) {
99
+ const current = row[key];
100
+ if (typeof current !== "string") continue;
101
+ const mapped = enums[key]![direction][current];
102
+ // An unknown member is left as it is rather than blanked: a value written before an
103
+ // option was removed should still be visible in the admin, not silently become null.
104
+ if (mapped === undefined || mapped === current) continue;
105
+ copy ??= { ...row };
106
+ copy[key] = mapped;
107
+ }
108
+ return copy ?? row;
109
+ }
110
+
111
+ export function prismaRows(delegate: PrismaDelegate, client: unknown, resource?: Resource): ResourceRows {
112
+ // Built once per store rather than per row. Without a descriptor there is nothing to
113
+ // translate, which keeps the old two-argument call working.
114
+ const enums = resource ? enumTranslations(storedFields(resource)) : {};
115
+ const hasEnums = Object.keys(enums).length > 0;
116
+ const toValue = (row: Row): Row => (hasEnums ? translate(row, enums, "toValue") : row);
117
+ const toMember = (row: Row): Row => (hasEnums ? translate(row, enums, "toMember") : row);
83
118
  /** The search and filter half of a where clause — everything but the cursor. */
84
119
  function matching(query: Pick<RowsQuery, "search" | "filters" | "deleted">): Record<string, unknown> {
85
- const where: Record<string, unknown> = { ...query.filters };
120
+ // A filter on an enum field arrives as the value the user sees, and has to reach
121
+ // Prisma as the member name.
122
+ const where: Record<string, unknown> = hasEnums ? { ...toMember(query.filters as Row) } : { ...query.filters };
86
123
  // A soft-deleting resource hides its deleted rows. Set on the query rather than in
87
124
  // `filters` because "only" is `not: null`, which the filter shape cannot say.
88
125
  if (query.deleted === "exclude") where.deletedAt = null;
@@ -106,18 +143,22 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
106
143
  // Postgres compares a timestamp against a Date, not the number the cursor
107
144
  // carried. Without this, paging past the first page of a date-sorted list fails
108
145
  // with "Expected DateTime, provided Int".
109
- const value = isDate && (typeof query.cursor.value === "number" || typeof query.cursor.value === "string") ? new Date(query.cursor.value) : query.cursor.value;
146
+ let value = isDate && (typeof query.cursor.value === "number" || typeof query.cursor.value === "string") ? new Date(query.cursor.value) : query.cursor.value;
147
+ // Paging over an enum column: the cursor carries the value the row was handed
148
+ // out with, and Prisma compares member names.
149
+ if (hasEnums && enums[field] && typeof value === "string") value = enums[field]!.toMember[value] ?? value;
110
150
  // (sort, id) as one comparison, so rows sharing a sort value are neither skipped
111
151
  // nor repeated. AND-ed with the filters rather than replacing them.
112
152
  const keyset = [{ [field]: { [op]: value } }, { AND: [{ [field]: value }, { id: { [op]: id } }] }];
113
153
  where.AND = [...((where.AND as unknown[]) ?? []), { OR: keyset }];
114
154
  }
115
- return delegate.findMany({
155
+ const rows = await delegate.findMany({
116
156
  where,
117
157
  orderBy: [{ [query.sort.field]: query.sort.direction }, { id: query.sort.direction }],
118
158
  take: query.limit,
119
159
  ...(query.offset ? { skip: query.offset } : {}),
120
160
  });
161
+ return hasEnums ? rows.map(toValue) : rows;
121
162
  },
122
163
 
123
164
  countUpTo(query, limit) {
@@ -125,8 +166,9 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
125
166
  return delegate.count({ where: matching(query), take: limit });
126
167
  },
127
168
 
128
- byId(id) {
129
- return delegate.findUnique({ where: { id } });
169
+ async byId(id) {
170
+ const row = await delegate.findUnique({ where: { id } });
171
+ return row ? toValue(row) : row;
130
172
  },
131
173
 
132
174
  async titles(ids, titleField, owner) {
@@ -135,13 +177,14 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
135
177
  return rows.map((row) => ({ id: String(row.id), title: row[titleField] }));
136
178
  },
137
179
 
138
- insert(values) {
139
- return delegate.create({ data: values });
180
+ async insert(values) {
181
+ return toValue(await delegate.create({ data: hasEnums ? toMember(values as Row) : values }));
140
182
  },
141
183
 
142
184
  async update(id, values) {
143
185
  try {
144
- return await delegate.update({ where: { id }, data: values });
186
+ const row = await delegate.update({ where: { id }, data: hasEnums ? toMember(values as Row) : values });
187
+ return toValue(row);
145
188
  } catch (error) {
146
189
  if (codeOf(error) === NOT_FOUND) return null;
147
190
  throw error;
@@ -0,0 +1,128 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
6
+
7
+ /**
8
+ * Files on the local disk, the way Laravel's `local` disk works.
9
+ *
10
+ * For development without Docker, and for a single-server deployment where a volume is
11
+ * simpler than a bucket. It is not for anything that scales horizontally: two instances
12
+ * do not share a filesystem, and the second one serves 404s for everything the first
13
+ * one wrote.
14
+ *
15
+ * Bytes are served through this app's `/api/storage` route like R2's are, so a file is
16
+ * still private and still behind whatever policy issued the URL. Nothing is published
17
+ * by putting it here.
18
+ */
19
+
20
+ export interface DiskOptions {
21
+ /**
22
+ * Where the files go, relative to the project root unless absolute.
23
+ * Default `storage/app` — Laravel's name for the same thing.
24
+ */
25
+ dir?: string;
26
+ }
27
+
28
+ /**
29
+ * The content type is kept beside the object rather than guessed from its extension.
30
+ *
31
+ * A key is whatever `createObjectKey` was given, and people upload files called
32
+ * `report` and `IMG_0421`. Guessing would serve those as a download instead of showing
33
+ * them, and would be wrong rather than merely unhelpful for anything renamed. The
34
+ * sidecar lives in a parallel tree so it can never collide with a real key — somebody
35
+ * does, eventually, upload a file called `photo.png.meta`.
36
+ */
37
+ interface Meta {
38
+ contentType?: string;
39
+ }
40
+
41
+ export function diskBucket(options: DiskOptions = {}): StorageBucket {
42
+ const root = resolve(options.dir ?? join(process.cwd(), "storage", "app"));
43
+ const objects = join(root, "objects");
44
+ const metas = join(root, "meta");
45
+
46
+ /**
47
+ * A key as a path under `base`, confined to it.
48
+ *
49
+ * `createStorage` already rejects `..` and leading slashes before a key reaches a
50
+ * bucket. This is the second lock on the same door: a bucket is a public function and
51
+ * a path traversal here reads any file the process can.
52
+ */
53
+ const pathFor = (base: string, key: string, suffix = "") => {
54
+ const full = resolve(join(base, key + suffix));
55
+ if (full !== base && !full.startsWith(base + sep)) throw new Error(`Refusing to touch "${key}": it escapes the storage directory.`);
56
+ return full;
57
+ };
58
+
59
+ const write = async (file: string, data: Buffer | string) => {
60
+ try {
61
+ await mkdir(dirname(file), { recursive: true });
62
+ await writeFile(file, data);
63
+ } catch (error) {
64
+ // The one failure worth explaining. A serverless host gives you a read-only
65
+ // filesystem, so this driver cannot work there at all — and `EROFS` on a path
66
+ // nobody chose is a puzzling way to find that out, usually in production.
67
+ const code = (error as NodeJS.ErrnoException).code;
68
+ if (code === "EROFS" || code === "EACCES" || code === "EPERM") {
69
+ throw new Error(
70
+ `The disk driver cannot write to ${root} (${code}). A serverless host — Vercel, Lambda, Workers — ` +
71
+ "has no writable filesystem that survives a request, so set STORAGE_DRIVER to r2, cloudinary or " +
72
+ "uploadthing for anything deployed there. Disk is for local development and for a server you own.",
73
+ );
74
+ }
75
+ throw error;
76
+ }
77
+ };
78
+
79
+ return {
80
+ async get(key: string): Promise<StorageObjectBody | null> {
81
+ const file = pathFor(objects, key);
82
+ let size: number;
83
+ let mtimeMs: number;
84
+ try {
85
+ const info = await stat(file);
86
+ if (!info.isFile()) return null;
87
+ size = info.size;
88
+ mtimeMs = info.mtimeMs;
89
+ } catch {
90
+ return null;
91
+ }
92
+
93
+ let meta: Meta = {};
94
+ try {
95
+ meta = JSON.parse(await readFile(pathFor(metas, key, ".json"), "utf8")) as Meta;
96
+ } catch {
97
+ // Written before the sidecar existed, or removed by hand. The object is still
98
+ // the thing being asked for, so serve it as bytes rather than refusing.
99
+ }
100
+
101
+ return {
102
+ // Node's stream, as the web stream the route writes to the response.
103
+ body: Readable.toWeb(createReadStream(file)) as ReadableStream,
104
+ size,
105
+ // Strong enough for a cache: a write changes one or the other.
106
+ httpEtag: `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`,
107
+ httpMetadata: { contentType: meta.contentType },
108
+ };
109
+ },
110
+
111
+ async put(key, value, putOptions) {
112
+ // The route hands over a stream of known length, already capped by the field's
113
+ // maxBytes and checked against its declared type.
114
+ const body = value === null ? Buffer.alloc(0) : Buffer.from(await new Response(value as BodyInit).arrayBuffer());
115
+ await write(pathFor(objects, key), body);
116
+ const contentType = putOptions?.httpMetadata?.contentType;
117
+ if (contentType) await write(pathFor(metas, key, ".json"), JSON.stringify({ contentType } satisfies Meta));
118
+ },
119
+
120
+ async delete(keys) {
121
+ for (const key of [keys].flat()) {
122
+ // `force` so deleting what was never there is the success it describes.
123
+ await rm(pathFor(objects, key), { force: true });
124
+ await rm(pathFor(metas, key, ".json"), { force: true });
125
+ }
126
+ },
127
+ };
128
+ }