@flaredev/cli 0.2.0 → 0.3.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/bin/flare.js +9 -1
- package/dist/index.js +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traffic, errors and latency for this Worker, from Cloudflare's GraphQL Analytics API.
|
|
3
|
+
*
|
|
4
|
+
* Needs two secrets, and works without them: `CLOUDFLARE_ACCOUNT_ID` and
|
|
5
|
+
* `CLOUDFLARE_API_TOKEN` (a token with Account Analytics: Read). Without them the
|
|
6
|
+
* dashboard says so and shows the rest of the page, because analytics is the one part
|
|
7
|
+
* of an app that can't be read from its own database.
|
|
8
|
+
*
|
|
9
|
+
* wrangler secret put CLOUDFLARE_API_TOKEN
|
|
10
|
+
*/
|
|
11
|
+
import { env } from "cloudflare:workers";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
|
|
14
|
+
|
|
15
|
+
export interface RequestPoint {
|
|
16
|
+
/** "2026-09-22" */
|
|
17
|
+
date: string;
|
|
18
|
+
requests: number;
|
|
19
|
+
errors: number;
|
|
20
|
+
/** Median CPU time in milliseconds. */
|
|
21
|
+
medianCpuMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkerAnalytics {
|
|
25
|
+
points: RequestPoint[];
|
|
26
|
+
requests: number;
|
|
27
|
+
errors: number;
|
|
28
|
+
/** Errors as a percentage of requests. */
|
|
29
|
+
errorRate: number;
|
|
30
|
+
medianCpuMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Why there's nothing to show, when there's nothing to show. */
|
|
34
|
+
export type AnalyticsResult = { ok: true; data: WorkerAnalytics } | { ok: false; reason: "unconfigured" | "failed"; message: string };
|
|
35
|
+
|
|
36
|
+
interface GraphQlResponse {
|
|
37
|
+
data?: {
|
|
38
|
+
viewer?: {
|
|
39
|
+
accounts?: {
|
|
40
|
+
workersInvocationsAdaptive?: {
|
|
41
|
+
dimensions: { date: string };
|
|
42
|
+
sum: { requests: number; errors: number };
|
|
43
|
+
quantiles: { cpuTimeP50: number };
|
|
44
|
+
}[];
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
errors?: { message: string }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const QUERY = `query WorkerTraffic($accountTag: String!, $scriptName: String!, $since: Date!, $until: Date!) {
|
|
52
|
+
viewer {
|
|
53
|
+
accounts(filter: { accountTag: $accountTag }) {
|
|
54
|
+
workersInvocationsAdaptive(
|
|
55
|
+
limit: 100
|
|
56
|
+
filter: { scriptName: $scriptName, date_geq: $since, date_leq: $until }
|
|
57
|
+
orderBy: [date_ASC]
|
|
58
|
+
) {
|
|
59
|
+
dimensions { date }
|
|
60
|
+
sum { requests errors }
|
|
61
|
+
quantiles { cpuTimeP50 }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}`;
|
|
66
|
+
|
|
67
|
+
/** Traffic for the last `days` days, a point per day. */
|
|
68
|
+
export async function workerAnalytics(scriptName: string, days = 7): Promise<AnalyticsResult> {
|
|
69
|
+
const vars = env as unknown as Record<string, string | undefined>;
|
|
70
|
+
const accountTag = vars.CLOUDFLARE_ACCOUNT_ID;
|
|
71
|
+
const token = vars.CLOUDFLARE_API_TOKEN;
|
|
72
|
+
if (!accountTag || !token) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "unconfigured",
|
|
76
|
+
message: "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to read traffic, errors and CPU time from Cloudflare.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const day = (offset: number) => new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(ENDPOINT, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
query: QUERY,
|
|
87
|
+
variables: { accountTag, scriptName, since: day(days - 1), until: day(0) },
|
|
88
|
+
}),
|
|
89
|
+
signal: AbortSignal.timeout(8000),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
return { ok: false, reason: "failed", message: `Cloudflare answered ${response.status}. Check the token's Account Analytics: Read permission.` };
|
|
93
|
+
}
|
|
94
|
+
const body = (await response.json()) as GraphQlResponse;
|
|
95
|
+
if (body.errors?.length) return { ok: false, reason: "failed", message: body.errors[0]!.message };
|
|
96
|
+
|
|
97
|
+
const series = body.data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
|
|
98
|
+
const points: RequestPoint[] = series.map((entry) => ({
|
|
99
|
+
date: entry.dimensions.date,
|
|
100
|
+
requests: entry.sum.requests,
|
|
101
|
+
errors: entry.sum.errors,
|
|
102
|
+
// Cloudflare reports CPU time in microseconds.
|
|
103
|
+
medianCpuMs: Math.round((entry.quantiles.cpuTimeP50 / 1000) * 100) / 100,
|
|
104
|
+
}));
|
|
105
|
+
const requests = points.reduce((total, point) => total + point.requests, 0);
|
|
106
|
+
const errors = points.reduce((total, point) => total + point.errors, 0);
|
|
107
|
+
const medians = points.map((point) => point.medianCpuMs).sort((a, b) => a - b);
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
data: {
|
|
111
|
+
points,
|
|
112
|
+
requests,
|
|
113
|
+
errors,
|
|
114
|
+
errorRate: requests > 0 ? (errors / requests) * 100 : 0,
|
|
115
|
+
medianCpuMs: medians.length ? medians[Math.floor(medians.length / 2)]! : 0,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { ok: false, reason: "failed", message: error instanceof Error ? error.message : "Couldn't reach Cloudflare." };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { desc, eq, gte, sql } from "drizzle-orm";
|
|
3
|
+
import { getDb } from "@/db";
|
|
4
|
+
import { auditLog } from "@/db/flare-schema";
|
|
5
|
+
import { dashboardSession } from "./dashboard";
|
|
6
|
+
|
|
7
|
+
export type AuditAction = "create" | "update" | "delete" | "import" | "bulk-delete";
|
|
8
|
+
|
|
9
|
+
export interface AuditEntry {
|
|
10
|
+
action: AuditAction;
|
|
11
|
+
resource: string;
|
|
12
|
+
recordId?: string | null;
|
|
13
|
+
recordLabel?: string | null;
|
|
14
|
+
changes?: Record<string, unknown> | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Record a change in the audit log.
|
|
19
|
+
*
|
|
20
|
+
* Never throws: an audit trail that can take the write down with it is worse than one
|
|
21
|
+
* with a gap, and the caller has already changed the database by the time we're here.
|
|
22
|
+
*/
|
|
23
|
+
export async function recordAudit(entry: AuditEntry): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
const { session } = await dashboardSession();
|
|
26
|
+
const headerList = await headers();
|
|
27
|
+
await getDb()
|
|
28
|
+
.insert(auditLog)
|
|
29
|
+
.values({
|
|
30
|
+
action: entry.action,
|
|
31
|
+
resource: entry.resource,
|
|
32
|
+
recordId: entry.recordId ?? null,
|
|
33
|
+
recordLabel: entry.recordLabel ?? null,
|
|
34
|
+
userId: session?.user.id ?? null,
|
|
35
|
+
userEmail: session?.user.email ?? null,
|
|
36
|
+
changes: entry.changes ?? null,
|
|
37
|
+
ip: headerList.get("cf-connecting-ip") ?? null,
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
// Deliberately quiet.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Which fields moved, and from what to what. Only the differences, never the whole record. */
|
|
45
|
+
export function diffFields(before: Record<string, unknown> | null | undefined, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
|
|
46
|
+
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
|
47
|
+
for (const [key, value] of Object.entries(after)) {
|
|
48
|
+
if (key === "updatedAt" || key === "createdAt") continue;
|
|
49
|
+
const previous = before?.[key];
|
|
50
|
+
const same = JSON.stringify(previous ?? null) === JSON.stringify(value ?? null);
|
|
51
|
+
if (!same) changes[key] = { from: previous ?? null, to: value ?? null };
|
|
52
|
+
}
|
|
53
|
+
return changes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuditRow {
|
|
57
|
+
id: string;
|
|
58
|
+
action: string;
|
|
59
|
+
resource: string;
|
|
60
|
+
recordId: string | null;
|
|
61
|
+
recordLabel: string | null;
|
|
62
|
+
userEmail: string | null;
|
|
63
|
+
changes: Record<string, unknown> | null;
|
|
64
|
+
createdAt: Date;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The most recent entries, newest first. */
|
|
68
|
+
export async function recentAudit(limit = 25, resource?: string): Promise<AuditRow[]> {
|
|
69
|
+
const db = getDb();
|
|
70
|
+
const query = db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
|
|
71
|
+
const rows = resource ? await query.where(eq(auditLog.resource, resource)) : await query;
|
|
72
|
+
return rows as AuditRow[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How many changes were recorded in the last `days` days, for the observability summary. */
|
|
76
|
+
export async function auditCount(days = 7): Promise<number> {
|
|
77
|
+
const since = new Date(Date.now() - days * 86_400_000);
|
|
78
|
+
const [row] = await getDb().select({ total: sql<number>`count(*)` }).from(auditLog).where(gte(auditLog.createdAt, since));
|
|
79
|
+
return row?.total ?? 0;
|
|
80
|
+
}
|
|
@@ -1,33 +1,38 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How people sign in to this app. `flare create` wrote the choices you made; change
|
|
3
|
-
* them here any time. Every method's tables already exist, so switching one on or off
|
|
4
|
-
* needs no migration, and a method that's off is refused by the API, not just hidden.
|
|
5
|
-
*
|
|
6
|
-
* Email and password sign-in is always available.
|
|
7
|
-
*/
|
|
8
|
-
export const authConfig = {
|
|
9
|
-
/** A sign-in link by email. */
|
|
10
|
-
magicLink: true,
|
|
11
|
-
/** A 6-digit sign-in code by email. */
|
|
12
|
-
emailOtp: true,
|
|
13
|
-
/** Face ID, Touch ID, Windows Hello or a security key. */
|
|
14
|
-
passkeys: true,
|
|
15
|
-
twoFactor: {
|
|
16
|
-
/** Codes from an authenticator app (TOTP), with backup codes. */
|
|
17
|
-
authenticator: true,
|
|
18
|
-
/** Codes by email as the second step. */
|
|
19
|
-
email: true,
|
|
20
|
-
},
|
|
21
|
-
/**
|
|
22
|
-
* Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
|
|
23
|
-
* without them its button stays hidden.
|
|
24
|
-
*/
|
|
25
|
-
social: [] as SocialProvider[],
|
|
26
|
-
/** Refuse password sign-in until the email address is verified. */
|
|
27
|
-
requireEmailVerification: false,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
/**
|
|
2
|
+
* How people sign in to this app. `flare create` wrote the choices you made; change
|
|
3
|
+
* them here any time. Every method's tables already exist, so switching one on or off
|
|
4
|
+
* needs no migration, and a method that's off is refused by the API, not just hidden.
|
|
5
|
+
*
|
|
6
|
+
* Email and password sign-in is always available.
|
|
7
|
+
*/
|
|
8
|
+
export const authConfig = {
|
|
9
|
+
/** A sign-in link by email. */
|
|
10
|
+
magicLink: true,
|
|
11
|
+
/** A 6-digit sign-in code by email. */
|
|
12
|
+
emailOtp: true,
|
|
13
|
+
/** Face ID, Touch ID, Windows Hello or a security key. */
|
|
14
|
+
passkeys: true,
|
|
15
|
+
twoFactor: {
|
|
16
|
+
/** Codes from an authenticator app (TOTP), with backup codes. */
|
|
17
|
+
authenticator: true,
|
|
18
|
+
/** Codes by email as the second step. */
|
|
19
|
+
email: true,
|
|
20
|
+
},
|
|
21
|
+
/**
|
|
22
|
+
* Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
|
|
23
|
+
* without them its button stays hidden.
|
|
24
|
+
*/
|
|
25
|
+
social: [] as SocialProvider[],
|
|
26
|
+
/** Refuse password sign-in until the email address is verified. */
|
|
27
|
+
requireEmailVerification: false,
|
|
28
|
+
/**
|
|
29
|
+
* Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
30
|
+
* characters of the password's hash are sent, and an outage lets the password through.
|
|
31
|
+
*/
|
|
32
|
+
checkBreachedPasswords: true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
36
|
+
|
|
37
|
+
/** Whether any second factor can be set up. */
|
|
38
|
+
export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
|
|
@@ -11,6 +11,7 @@ import { twoFactor } from "better-auth/plugins/two-factor";
|
|
|
11
11
|
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
12
|
import { getDb, schema } from "@/db";
|
|
13
13
|
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
|
+
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
14
15
|
import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
|
|
15
16
|
|
|
16
17
|
const vars = env as unknown as Record<string, string | undefined>;
|
|
@@ -51,6 +52,38 @@ function disabledPath(path: string): boolean {
|
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** Where a new password arrives, and what it's called in the body. */
|
|
56
|
+
const NEW_PASSWORD_FIELDS: Record<string, string> = {
|
|
57
|
+
"/sign-up/email": "password",
|
|
58
|
+
"/reset-password": "newPassword",
|
|
59
|
+
"/change-password": "newPassword",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same password rules the sign-up and reset forms show as you type
|
|
64
|
+
* (lib/password-rules.ts), enforced here so the API agrees with the screens.
|
|
65
|
+
* Checked on the way in, before the password is hashed or stored.
|
|
66
|
+
*/
|
|
67
|
+
async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
|
|
68
|
+
const field = NEW_PASSWORD_FIELDS[path];
|
|
69
|
+
if (!field) return;
|
|
70
|
+
const password = body?.[field];
|
|
71
|
+
if (typeof password !== "string") return;
|
|
72
|
+
|
|
73
|
+
if (password.length > PASSWORD_MAX_LENGTH) {
|
|
74
|
+
throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
|
|
75
|
+
}
|
|
76
|
+
const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
|
|
77
|
+
if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
|
|
78
|
+
|
|
79
|
+
// A password in a public breach list is already guessed, however strong it looks.
|
|
80
|
+
if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
|
|
81
|
+
throw new APIError("BAD_REQUEST", {
|
|
82
|
+
message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
export const auth = betterAuth({
|
|
55
88
|
appName: "__APP_NAME__",
|
|
56
89
|
secret: env.BETTER_AUTH_SECRET,
|
|
@@ -90,6 +123,7 @@ export const auth = betterAuth({
|
|
|
90
123
|
hooks: {
|
|
91
124
|
before: createAuthMiddleware(async (ctx) => {
|
|
92
125
|
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
126
|
+
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
93
127
|
}),
|
|
94
128
|
},
|
|
95
129
|
plugins: [
|
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
-
import type { ChangeEvent } from "@flaredev/core/server";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Caching for data you read far more often than you write.
|
|
6
|
-
*
|
|
7
|
-
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
-
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
-
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
-
* its tag is revalidated.
|
|
11
|
-
*
|
|
12
|
-
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
-
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
-
*
|
|
15
|
-
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
-
* outside the cache, since these entries are shared between visitors.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
-
export const TTL = {
|
|
21
|
-
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
-
short: 60,
|
|
23
|
-
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
-
medium: 5 * 60,
|
|
25
|
-
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
-
long: 60 * 60,
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
|
-
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
-
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
-
|
|
32
|
-
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
-
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
-
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
-
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
-
*/
|
|
40
|
-
function checkTag(tag: string): string {
|
|
41
|
-
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
-
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
-
}
|
|
44
|
-
return tag;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Cache the result of an async function in the data cache.
|
|
49
|
-
*
|
|
50
|
-
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
-
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
-
* headers, or anything else request-specific — the value is shared.
|
|
53
|
-
*
|
|
54
|
-
* ```ts
|
|
55
|
-
* const openDeals = cached(
|
|
56
|
-
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
-
* ["deals", "open-count"],
|
|
58
|
-
* { tags: [resourceTag("Deal")] },
|
|
59
|
-
* );
|
|
60
|
-
* ```
|
|
61
|
-
*/
|
|
62
|
-
export function cached<Args extends unknown[], Value>(
|
|
63
|
-
fn: (...args: Args) => Promise<Value>,
|
|
64
|
-
keyParts: string[],
|
|
65
|
-
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
-
) {
|
|
67
|
-
return unstable_cache(fn, keyParts, {
|
|
68
|
-
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
-
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
-
* `lib/
|
|
76
|
-
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
-
*/
|
|
78
|
-
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
-
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
-
// A record someone just edited should never come back with its old values.
|
|
81
|
-
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
-
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
-
}
|
|
1
|
+
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
+
import type { ChangeEvent } from "@flaredev/core/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Caching for data you read far more often than you write.
|
|
6
|
+
*
|
|
7
|
+
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
+
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
+
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
+
* its tag is revalidated.
|
|
11
|
+
*
|
|
12
|
+
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
+
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
+
* outside the cache, since these entries are shared between visitors.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
+
export const TTL = {
|
|
21
|
+
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
+
short: 60,
|
|
23
|
+
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
+
medium: 5 * 60,
|
|
25
|
+
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
+
long: 60 * 60,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
+
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
+
|
|
32
|
+
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
+
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
+
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
+
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
+
*/
|
|
40
|
+
function checkTag(tag: string): string {
|
|
41
|
+
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
+
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
+
}
|
|
44
|
+
return tag;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cache the result of an async function in the data cache.
|
|
49
|
+
*
|
|
50
|
+
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
+
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
+
* headers, or anything else request-specific — the value is shared.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const openDeals = cached(
|
|
56
|
+
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
+
* ["deals", "open-count"],
|
|
58
|
+
* { tags: [resourceTag("Deal")] },
|
|
59
|
+
* );
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function cached<Args extends unknown[], Value>(
|
|
63
|
+
fn: (...args: Args) => Promise<Value>,
|
|
64
|
+
keyParts: string[],
|
|
65
|
+
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
+
) {
|
|
67
|
+
return unstable_cache(fn, keyParts, {
|
|
68
|
+
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
+
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
+
* `lib/dashboard.ts` and `lib/api.ts` pass this to the store, so writes through the
|
|
76
|
+
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
+
*/
|
|
78
|
+
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
+
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
+
// A record someone just edited should never come back with its old values.
|
|
81
|
+
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
+
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV, the way spreadsheets expect it (RFC 4180): fields holding a comma, a quote or a
|
|
3
|
+
* line break are quoted, and quotes inside them are doubled.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the table's "export selected" (in the browser) and the export action (on
|
|
6
|
+
* the server), so both produce the same file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CsvColumn {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function csvCell(value: unknown): string {
|
|
15
|
+
if (value === null || value === undefined) return "";
|
|
16
|
+
const text = Array.isArray(value) ? value.join("; ") : value instanceof Date ? value.toISOString() : String(value);
|
|
17
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string {
|
|
21
|
+
const header = columns.map((column) => csvCell(column.label)).join(",");
|
|
22
|
+
const body = rows.map((row) => columns.map((column) => csvCell(row[column.key])).join(","));
|
|
23
|
+
return [header, ...body].join("\r\n");
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Extra dashboard sidebar links (maintained by flare generators such as `flare gen security`).
|
|
2
|
+
// Add your own links outside the generated block.
|
|
3
|
+
// generated:start
|
|
4
|
+
export interface DashboardLink {
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
8
|
+
icon: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const generatedDashboardLinks: DashboardLink[] = [];
|
|
12
|
+
// generated:end
|
|
13
|
+
|
|
14
|
+
/** Links shown under "Platform" in the dashboard sidebar. */
|
|
15
|
+
export const dashboardLinks: DashboardLink[] = [...generatedDashboardLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
|