Merge remote-tracking branch 'origin/dev' into devin/1782257319-migrate-config-to-jiti

This commit is contained in:
mantrakp04 2026-06-29 10:23:43 -07:00
commit 6cf0e899a0
47 changed files with 431 additions and 188 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/backend",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,10 +1,48 @@
import { EmailOutboxCreatedWith } from "@/generated/prisma/client";
import { globalPrismaClient } from "@/prisma-client";
import { afterAll, describe, expect, it } from "vitest";
import { afterAll, describe, expect, it, vi } from "vitest";
import { _forTesting } from "./email-queue-step";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "./tenancies";
const { failEmailsStuckInSending, STUCK_EMAIL_TIMEOUT_MS } = _forTesting;
const { failEmailsStuckInSending, STUCK_EMAIL_TIMEOUT_MS, updateLastExecutionTime } = _forTesting;
describe.sequential("updateLastExecutionTime", () => {
const metadataKeys: string[] = [];
afterAll(async () => {
await globalPrismaClient.emailOutboxProcessingMetadata.deleteMany({
where: { key: { in: metadataKeys } },
});
});
it("does not move lastExecutedAt backwards when the stored timestamp is ahead", async () => {
const key = `email-queue-step-delta-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
metadataKeys.push(key);
const futureTimestamp = new Date(Date.now() + 60_000);
await globalPrismaClient.emailOutboxProcessingMetadata.create({
data: {
key,
lastExecutedAt: futureTimestamp,
},
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const delta = await updateLastExecutionTime(key);
expect(delta).toBe(0);
expect(warnSpy).not.toHaveBeenCalled();
const after = await globalPrismaClient.emailOutboxProcessingMetadata.findUniqueOrThrow({
where: { key },
});
expect(after.lastExecutedAt?.toISOString()).toBe(futureTimestamp.toISOString());
} finally {
warnSpy.mockRestore();
}
});
});
// These tests connect to the real dev DB (like payments.test.tsx) and create real EmailOutbox
// rows against the seeded `internal` tenancy. Each row is tagged with a unique tsxSource so we

View File

@ -203,32 +203,40 @@ async function failEmailsStuckInSending(additionalWhere?: Prisma.EmailOutboxWher
export const _forTesting = {
failEmailsStuckInSending,
STUCK_EMAIL_TIMEOUT_MS,
updateLastExecutionTime,
};
async function updateLastExecutionTime(): Promise<number> {
const key = "EMAIL_QUEUE_METADATA_KEY";
async function updateLastExecutionTime(key = "EMAIL_QUEUE_METADATA_KEY"): Promise<number> {
// This query atomically claims the next execution slot and returns the delta.
// It uses FOR UPDATE to lock the row, preventing concurrent workers from reading
// the same previous timestamp. The pattern is:
// It uses FOR UPDATE to lock the row, preventing concurrent workers from reading the
// same previous timestamp. Use clock_timestamp(), not NOW(): NOW() is fixed at the
// transaction start, so a transaction that started earlier but acquired the row lock
// later could otherwise move lastExecutedAt backwards by a few milliseconds.
// The pattern is:
// 1. Try UPDATE first (locks row with FOR UPDATE, returns old and new timestamps)
// 2. If no row exists, INSERT (with ON CONFLICT DO NOTHING for race handling)
// 3. Compute delta based on the result
const [{ delta }] = await globalPrismaClient.$queryRaw<{ delta: number }[]>`
WITH now_ts AS (
SELECT NOW() AS now
),
do_update AS (
WITH do_update AS (
-- Update existing row, locking it first and capturing the old timestamp
UPDATE "EmailOutboxProcessingMetadata" AS m
SET
"updatedAt" = (SELECT now FROM now_ts),
"lastExecutedAt" = (SELECT now FROM now_ts)
"updatedAt" = old.next_timestamp,
"lastExecutedAt" = old.next_timestamp
FROM (
SELECT "key", "lastExecutedAt" AS previous_timestamp
FROM "EmailOutboxProcessingMetadata"
WHERE "key" = ${key}
FOR UPDATE
SELECT
locked."key",
locked."lastExecutedAt" AS previous_timestamp,
GREATEST(locked.observed_timestamp, COALESCE(locked."lastExecutedAt", locked.observed_timestamp)) AS next_timestamp
FROM (
SELECT
"key",
"lastExecutedAt",
clock_timestamp()::timestamp(3) AS observed_timestamp
FROM "EmailOutboxProcessingMetadata"
WHERE "key" = ${key}
FOR UPDATE
) AS locked
) AS old
WHERE m."key" = old."key"
RETURNING old.previous_timestamp, m."lastExecutedAt" AS new_timestamp
@ -236,7 +244,8 @@ async function updateLastExecutionTime(): Promise<number> {
do_insert AS (
-- Insert new row if no existing row was updated
INSERT INTO "EmailOutboxProcessingMetadata" ("key", "lastExecutedAt", "updatedAt")
SELECT ${key}, (SELECT now FROM now_ts), (SELECT now FROM now_ts)
SELECT ${key}, observed_timestamp, observed_timestamp
FROM (SELECT clock_timestamp()::timestamp(3) AS observed_timestamp) AS now_ts
WHERE NOT EXISTS (SELECT 1 FROM do_update)
ON CONFLICT ("key") DO NOTHING
RETURNING NULL::timestamp AS previous_timestamp, "lastExecutedAt" AS new_timestamp
@ -261,8 +270,7 @@ async function updateLastExecutionTime(): Promise<number> {
`;
if (delta < 0) {
// TODO: why does this happen, actually? investigate.
console.warn("Email queue step delta is negative. Not sure why it happened. Ignoring the delta. TODO investigate", { delta });
console.warn("Email queue step delta is negative after monotonic timestamp update; ignoring the delta so the send quota cannot go negative", { delta });
return 0;
}

View File

@ -2174,7 +2174,7 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis
branchId: DEFAULT_BRANCH_ID,
source: {
type: "pushed-from-github",
owner: "hexcalve",
owner: "hexclave",
repo: "config-test",
branch: "main",
commit_hash: "abc123def456789",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dashboard",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -73,12 +73,50 @@ function buildCloudSetupPrompt(options: {
`;
}
function buildCliDevSetupPrompt(options: {
docsBaseUrl: string,
}) {
const { docsBaseUrl } = options;
const normalizedDocsBaseUrl = docsBaseUrl.replace(/\/$/, '');
const reminders = remindersPrompt.replaceAll(PROD_DOCS_BASE_URL, normalizedDocsBaseUrl);
return deindent`
Install and set up Hexclave in this project by following these instructions:
Read https://skill.hexclave.com and follow the setup instructions it gives for this project's specific framework and language.
Follow skill.hexclave.com as written, but use the local dashboard / Hexclave CLI development setup. Do not use the cloud environment-variable setup for local development.
Set up the app's dev command so Hexclave starts through the CLI:
\`\`\`json
{
"scripts": {
"dev": "hexclave dev --config-file ./hexclave.config.ts -- npm run dev:inner",
"dev:inner": "<the app's previous dev command>"
}
}
\`\`\`
If the Hexclave CLI is not installed globally, use \`npx @hexclave/cli dev --config-file ./hexclave.config.ts -- npm run dev:inner\` instead.
Do not create Hexclave project keys or ask for Hexclave environment variables for local development. The \`hexclave dev\` command automatically creates or links the local config project and injects the project ID and secret server key into the child app process.
Keep project configuration in \`hexclave.config.ts\`. Once setup is done, run \`npm run dev\` and create the first user in the app.
After setup finishes, verify that the Hexclave MCP server is registered in your AI client config name: \`hexclave\`, transport: \`http\`, URL: \`https://mcp.hexclave.com/mcp\`. If it is not registered, add it manually so future agents have live access to Hexclave docs and APIs.
${reminders}
`;
}
export default function SetupPage(props: { toMetrics: () => void }) {
const adminApp = useAdminApp();
const [setupMode, setSetupMode] = useState<SetupMode>("recommended");
const [keys, setKeys] = useState<{ projectId: string, publishableClientKey?: string, secretServerKey: string } | null>(null);
const projectConfig = adminApp.useProject().useConfig();
const requirePublishableClientKey = projectConfig.project.requirePublishableClientKey;
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
const onGenerateKeys = async () => {
const newKey = await adminApp.createInternalApiKey({
@ -96,11 +134,16 @@ export default function SetupPage(props: { toMetrics: () => void }) {
});
};
const selectedInstallPrompt = buildCloudSetupPrompt({
docsBaseUrl: getSetupDocsBaseUrl(),
projectId: adminApp.projectId,
apiBaseUrl: getSetupApiBaseUrl(),
});
const setupDocsBaseUrl = getSetupDocsBaseUrl();
const selectedInstallPrompt = isRemoteDevelopmentEnvironment
? buildCliDevSetupPrompt({
docsBaseUrl: setupDocsBaseUrl,
})
: buildCloudSetupPrompt({
docsBaseUrl: setupDocsBaseUrl,
projectId: adminApp.projectId,
apiBaseUrl: getSetupApiBaseUrl(),
});
const manualSetupDocsUrl = getManualSetupDocsUrl();
return (
@ -130,13 +173,18 @@ export default function SetupPage(props: { toMetrics: () => void }) {
variant='outline'
size='sm'
onClick={() => {
window.open(getSetupDocsBaseUrl(), '_blank');
window.open(setupDocsBaseUrl, '_blank');
}}
>
<BookIcon className="w-4 h-4 mr-2" />
Full Documentation
</DesignButton>
</Typography>
{isRemoteDevelopmentEnvironment && (
<Typography variant="secondary" className="max-w-xl">
For local config projects, run your app with <InlineCode>hexclave dev</InlineCode>. It injects the project ID and secret key automatically, so you do not need to create project keys or write Hexclave environment variables.
</Typography>
)}
</div>
</div>
@ -152,7 +200,31 @@ export default function SetupPage(props: { toMetrics: () => void }) {
{setupMode === "recommended" ? (
<div className="flex flex-col mt-4 mx-4">
<ol className="relative text-gray-500 border-s border-gray-200 dark:border-gray-700 dark:text-gray-400 ">
{[
{(isRemoteDevelopmentEnvironment ? [
{
step: 1,
title: "Copy Setup Prompt",
content: <div className="flex min-w-0 flex-col gap-4">
<CodeBlock
language="text"
content={selectedInstallPrompt}
customRender={
<pre className="max-h-[480px] overflow-y-auto whitespace-pre-wrap break-words p-4 text-sm leading-6 text-foreground">
{selectedInstallPrompt}
</pre>
}
title="Prompt for your AI agent"
icon="code"
maxHeight={480}
/>
</div>,
},
{
step: 2,
title: "Done",
content: <SetupRecommendedDoneStep onExploreDashboard={props.toMetrics} />,
},
] : [
{
step: 1,
title: "Copy Setup Prompt",
@ -186,7 +258,7 @@ export default function SetupPage(props: { toMetrics: () => void }) {
title: "Done",
content: <SetupRecommendedDoneStep onExploreDashboard={props.toMetrics} />,
},
].map((item) => (
]).map((item) => (
<li key={item.step} className={cn("ms-6 flex flex-col lg:flex-row gap-10 mb-20")}>
<div className="flex flex-col justify-center gap-2 max-w-[180px] min-w-[180px]">
<span className={`absolute flex items-center justify-center w-8 h-8 bg-zinc-100 dark:bg-zinc-800 rounded-full -start-4 ring-4 ring-white dark:ring-zinc-900`}>
@ -310,6 +382,22 @@ function SetupRecommendedDoneStep(props: { onExploreDashboard: () => void }) {
);
}
function CliDevSetupStep() {
return (
<div className="flex flex-col gap-4">
<Typography>
Start the app through the Hexclave CLI instead of copying project keys into an env file.
</Typography>
<div className={cn(codePanelShellClasses, "w-full overflow-x-auto p-4 font-mono text-sm")}>
hexclave dev --config-file ./hexclave.config.ts -- &lt;your-dev-command&gt;
</div>
<Typography variant="secondary">
The CLI creates or links the local config project and injects the project ID and secret server key into the child process automatically. Use <InlineCode>npx @hexclave/cli dev</InlineCode> if the CLI is not installed globally.
</Typography>
</div>
);
}
function HexclaveKeys(props: {
keys: { projectId: string, publishableClientKey?: string, secretServerKey: string } | null,
onGenerateKeys: () => Promise<void>,

View File

@ -1,11 +1,13 @@
"use client";
import { InternalApiKeyTable } from "@/components/data-table/api-key-table";
import { DesignAlert, DesignButton } from "@/components/design-components";
import { DesignAlert, DesignButton, DesignCard } from "@/components/design-components";
import { EnvKeys } from "@/components/env-keys";
import { SmartFormDialog } from "@/components/form-dialog";
import { SelectField } from "@/components/form-fields";
import { InlineCode } from "@/components/inline-code";
import { SettingSwitch } from "@/components/settings";
import { ActionDialog, Button, Typography } from "@/components/ui";
import { ActionDialog, Typography } from "@/components/ui";
import { getPublicEnvVar } from "@/lib/env";
import { InternalApiKeyFirstView } from "@hexclave/next";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
@ -15,15 +17,32 @@ import { useAdminApp } from "../use-admin-app";
export default function PageClient() {
const hexclaveAdminApp = useAdminApp();
const project = hexclaveAdminApp.useProject();
const params = useSearchParams();
const create = params.get("create") === "true";
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
const showLocalConfigInstructions = isRemoteDevelopmentEnvironment && project.isDevelopmentEnvironment;
if (showLocalConfigInstructions) {
return (
<PageLayout title="Project Keys">
<LocalConfigProjectKeysInstructions />
</PageLayout>
);
}
return <ProjectKeysManagement create={create} />;
}
function ProjectKeysManagement(props: { create: boolean }) {
const hexclaveAdminApp = useAdminApp();
const project = hexclaveAdminApp.useProject();
const config = project.useConfig();
const requirePublishableClientKey = config.project.requirePublishableClientKey;
const apiKeySets = hexclaveAdminApp.useInternalApiKeys();
const params = useSearchParams();
const create = params.get("create") === "true";
const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(create);
const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(props.create);
const [returnedApiKey, setReturnedApiKey] = useState<InternalApiKeyFirstView | null>(null);
return (
@ -66,6 +85,51 @@ export default function PageClient() {
);
}
function LocalConfigProjectKeysInstructions() {
const hexclaveAdminApp = useAdminApp();
const project = hexclaveAdminApp.useProject();
const config = project.useConfig();
const requirePublishableClientKey = config.project.requirePublishableClientKey;
return (
<>
<DesignCard glassmorphic contentClassName="space-y-5">
<DesignAlert
variant="info"
title="Project keys are managed by the Hexclave CLI for local configs"
description="Local config projects do not create project keys from the dashboard. The CLI starts the dashboard, creates or links the local project, and injects the project ID and secret server key into your app process."
/>
<div className="space-y-3">
<Typography>
Run your app through the CLI so Hexclave can keep <InlineCode>hexclave.config.ts</InlineCode> and your app environment in sync:
</Typography>
<div className="overflow-x-auto rounded-xl border border-border bg-foreground/[0.03] p-4 font-mono text-sm">
npx @hexclave/cli dev --config-file &lt;path-to-hexclave.config.ts&gt; -- &lt;your-dev-command&gt;
</div>
<Typography>
This will automatically provide the correct environment variables to the specified command.
</Typography>
<Typography>
If you have the CLI installed globally, the same command starts with <InlineCode>hexclave dev</InlineCode>. Keep project settings in the config file; the CLI provides the runtime keys automatically.
</Typography>
</div>
</DesignCard>
<SettingSwitch
label="[Advanced] Require publishable client keys"
hint="When enabled, client requests must include a publishable client key."
checked={requirePublishableClientKey}
onCheckedChange={async (checked) => {
await project.update({
requirePublishableClientKey: checked,
});
}}
/>
</>
);
}
const neverInMs = 1000 * 60 * 60 * 24 * 365 * 200;
const expiresInOptions = {
[1000 * 60 * 60 * 24 * 1]: "1 day",

View File

@ -426,7 +426,8 @@ function UserTableBody(props: {
fetchRows: fetchExportRows,
emptyExportTitle: "No users to export",
emptyExportDescription: "There are no users matching the current filters",
allScopeLabel: "Export all users in the project",
defaultScope: "filtered",
allScopeLabel: "Export all users in the project (includes Anonymous)",
filteredScopeLabel: (
<>
Export only filtered/searched users

View File

@ -3,6 +3,9 @@ import { ServerUser } from "@hexclave/next";
import { KnownErrors } from "@hexclave/shared";
import { countryCodeSchema, emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@hexclave/shared/dist/schema-fields";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Typography } from "@/components/ui";
import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
import { WarningCircleIcon } from "@phosphor-icons/react";
import { useState } from "react";
import * as yup from "yup";
import { FormDialog } from "./form-dialog";
import { CountryCodeField } from "./country-code-select";
@ -24,6 +27,7 @@ export function UserDialog(props: {
})) {
const adminApp = useAdminApp();
const project = adminApp.useProject();
const [errorDialog, setErrorDialog] = useState<{ title: string; description: string } | null>(null);
let defaultValues;
if (props.type === 'edit') {
@ -127,135 +131,159 @@ export function UserDialog(props: {
}
} catch (error) {
if (KnownErrors.UserWithEmailAlreadyExists.isInstance(error)) {
alert("Email already exists. Please choose a different email address.");
return 'prevent-close';
setErrorDialog({
title: "Email already exists",
description: "Please choose a different email address.",
});
return 'prevent-close-and-prevent-reset';
}
if (KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse.isInstance(error)) {
alert("Email already used for authentication. This email is already used for sign-in by another account. Please choose a different email address.");
return 'prevent-close';
setErrorDialog({
title: "Email already used for authentication",
description: "This email is already used for sign-in by another account. Please choose a different email address.",
});
return 'prevent-close-and-prevent-reset';
}
throw error;
}
}
return <FormDialog
open={props.open}
onOpenChange={props.onOpenChange}
trigger={props.trigger}
title={props.type === 'edit' ? "Edit User" : "Create User"}
formSchema={formSchema}
defaultValues={defaultValues}
okButton={{ label: props.type === 'edit' ? "Save" : "Create" }}
render={(form) => (
<>
{props.type === 'edit' ? <Typography variant='secondary'>ID: {props.user.id}</Typography> : null}
return <>
<DesignDialog
open={errorDialog !== null}
onOpenChange={(open: boolean) => { if (!open) setErrorDialog(null); }}
size="sm"
icon={WarningCircleIcon}
title={errorDialog?.title ?? ""}
description={errorDialog?.description ?? ""}
footer={
<DesignDialogClose asChild>
<DesignButton variant="secondary" size="sm">OK</DesignButton>
</DesignDialogClose>
}
/>
<FormDialog
open={props.open}
onOpenChange={(open: boolean) => {
if (!open) setErrorDialog(null);
props.onOpenChange?.(open);
}}
trigger={props.trigger}
title={props.type === 'edit' ? "Edit User" : "Create User"}
formSchema={formSchema}
defaultValues={defaultValues}
okButton={{ label: props.type === 'edit' ? "Save" : "Create" }}
render={(form) => (
<>
{props.type === 'edit' ? <Typography variant='secondary'>ID: {props.user.id}</Typography> : null}
<div className="flex gap-4 items-end">
<div className="flex-1">
<InputField control={form.control} label="Primary email" name="primaryEmail" required />
<div className="flex gap-4 items-end">
<div className="flex-1">
<InputField control={form.control} label="Primary email" name="primaryEmail" required />
</div>
<div className="mb-2">
<SwitchField control={form.control} label="Verified" name="primaryEmailVerified" />
</div>
</div>
<div className="mb-2">
<SwitchField control={form.control} label="Verified" name="primaryEmailVerified" />
</div>
</div>
<InputField control={form.control} label="Display name" name="displayName" />
<InputField control={form.control} label="Display name" name="displayName" />
<DateField control={form.control} label="Signed Up At" name="signedUpAt" />
<DateField control={form.control} label="Signed Up At" name="signedUpAt" />
{project.config.magicLinkEnabled && <SwitchField control={form.control} label="OTP/magic link sign-in" name="otpAuthEnabled" />}
{project.config.credentialEnabled && <SwitchField control={form.control} label="Password sign-in" name="passwordEnabled" />}
{form.watch("passwordEnabled") && (
props.type === 'edit' && !form.watch("password") && !form.watch("updatePassword") ? (
<Button
type="button"
variant="outline"
onClick={() => form.setValue('updatePassword', true)}
>
Update Password
</Button>
) : (
<InputField
control={form.control}
label={props.type === 'edit' ? "New password" : "Password"}
name="password"
type="password"
autoComplete="off"
/>
)
)}
{!form.watch("primaryEmailVerified") && form.watch("otpAuthEnabled") && <Typography variant="secondary">Primary email must be verified if OTP/magic link sign-in is enabled</Typography>}
{project.config.magicLinkEnabled && <SwitchField control={form.control} label="OTP/magic link sign-in" name="otpAuthEnabled" />}
{project.config.credentialEnabled && <SwitchField control={form.control} label="Password sign-in" name="passwordEnabled" />}
{form.watch("passwordEnabled") && (
props.type === 'edit' && !form.watch("password") && !form.watch("updatePassword") ? (
<Button
type="button"
variant="outline"
onClick={() => form.setValue('updatePassword', true)}
>
Update Password
</Button>
) : (
<InputField
control={form.control}
label={props.type === 'edit' ? "New password" : "Password"}
name="password"
type="password"
autoComplete="off"
/>
)
)}
{!form.watch("primaryEmailVerified") && form.watch("otpAuthEnabled") && <Typography variant="secondary">Primary email must be verified if OTP/magic link sign-in is enabled</Typography>}
{props.type === "create" && (
<Accordion type="single" collapsible>
<AccordionItem value="item-risk-and-geo">
<AccordionTrigger>Risk and Geo</AccordionTrigger>
<AccordionContent className="space-y-4">
<CountryCodeField control={form.control} label="Country code" name="countryCode" placeholder="Select country code..." />
<div className="grid gap-4 md:grid-cols-2">
<InputField control={form.control} label="Risk score: bot" name="botRiskScore" placeholder="0-100" />
<InputField control={form.control} label="Risk score: free trial abuse" name="freeTrialAbuseRiskScore" placeholder="0-100" />
</div>
<Typography variant="secondary">
Optional admin-only values for imports or custom anti-abuse systems. Leave blank to use the defaults.
</Typography>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{props.type === "create" && (
<Accordion type="single" collapsible>
<AccordionItem value="item-risk-and-geo">
<AccordionTrigger>Risk and Geo</AccordionTrigger>
<AccordionItem value="item-1">
<AccordionTrigger>Metadata</AccordionTrigger>
<AccordionContent className="space-y-4">
<CountryCodeField control={form.control} label="Country code" name="countryCode" placeholder="Select country code..." />
<div className="grid gap-4 md:grid-cols-2">
<InputField control={form.control} label="Risk score: bot" name="botRiskScore" placeholder="0-100" />
<InputField control={form.control} label="Risk score: free trial abuse" name="freeTrialAbuseRiskScore" placeholder="0-100" />
</div>
<Typography variant="secondary">
Optional admin-only values for imports or custom anti-abuse systems. Leave blank to use the defaults.
</Typography>
<TextAreaField
rows={3}
control={form.control}
label="Client metadata"
name="clientMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read and update; avoid sensitive data.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Client read only metadata"
name="clientReadOnlyMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read but only your backend can change.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Server metadata"
name="serverMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON reserved for server-side logic and never exposed to clients.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Metadata</AccordionTrigger>
<AccordionContent className="space-y-4">
<TextAreaField
rows={3}
control={form.control}
label="Client metadata"
name="clientMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read and update; avoid sensitive data.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Client read only metadata"
name="clientReadOnlyMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON clients can read but only your backend can change.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
<TextAreaField
rows={3}
control={form.control}
label="Server metadata"
name="serverMetadata"
placeholder="null"
monospace
helperText={
<>
Custom JSON reserved for server-side logic and never exposed to clients.{" "}
<StyledLink href={metadataDocsUrl} target="_blank">Learn more in the docs</StyledLink>.
</>
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</>
)}
onSubmit={handleSubmit}
cancelButton
/>;
</>
)}
onSubmit={handleSubmit}
cancelButton
/>
</>;
}

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dev-launchpad",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/e2e-tests",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/hosted-components",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"type": "module",
"scripts": {
"dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09",

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/internal-tool",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"type": "module",
"scripts": {
"dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/mcp",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/mock-oauth-server",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"main": "index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/skills",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs-mintlify",
"version": "1.0.36",
"version": "1.0.37",
"private": true,
"scripts": {
"dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"main": "index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-cjs-test",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/convex-example",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-demo-app",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/docs-examples",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"description": "",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/e-commerce-demo",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/js-example",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"description": "",

View File

@ -1,7 +1,7 @@
{
"name": "@hexclave/lovable-react-18-example",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-middleware-demo",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,7 +1,7 @@
{
"name": "react-example",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-supabase",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"private": true,
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/example-tanstack-start-demo",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"description": "TanStack Start demo app for Hexclave",
"private": true,

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/cli",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"description": "The CLI for Hexclave. https://hexclave.com",
"main": "dist/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/dashboard-ui-components",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,7 +1,7 @@
"use client";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DesignButton } from "../button";
import { DesignDialog } from "../dialog";
@ -55,7 +55,7 @@ export function DataGridExportDialog<TRow>({
[exportOptions?.fields, columns],
);
const [format, setFormat] = useState<DataGridExportFormat>("csv");
const [scope, setScope] = useState<DataGridExportScope>("all");
const [scope, setScope] = useState<DataGridExportScope>(exportOptions?.defaultScope ?? "all");
const [fields, setFields] = useState<readonly DataGridExportField<TRow>[]>(resolvedFields);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState<ExportProgress>(idleExportProgress);
@ -67,6 +67,22 @@ export function DataGridExportDialog<TRow>({
}
}, [isExporting, resolvedFields]);
// Reset the scope to its default each time the dialog opens. The dialog stays
// mounted between opens, so without this the scope would retain whatever the
// user last picked instead of honoring `defaultScope` on every open. We track
// the previous `open` value with a ref so the reset only fires on a genuine
// closed->open transition -- not on every render that flips other state (e.g.
// `isExporting` going false after a failed/empty export would otherwise wipe
// the user's current selection while the dialog is still open).
const defaultScope = exportOptions?.defaultScope ?? "all";
const wasOpenRef = useRef(false);
useEffect(() => {
if (open && !wasOpenRef.current) {
setScope(defaultScope);
}
wasOpenRef.current = open;
}, [open, defaultScope]);
const entityName = exportOptions?.entityName ?? "row";
const entityNamePlural = exportOptions?.entityNamePlural ?? "rows";
const filenamePrefix = exportOptions?.filenamePrefix ?? exportFilename;

View File

@ -251,6 +251,8 @@ export type DataGridExportOptions<TRow> = {
allScopeLabel?: ReactNode;
filteredScopeLabel?: ReactNode;
progressSubjectLabel?: string;
/** Which export scope is selected by default when the dialog opens. Defaults to `"all"`. */
defaultScope?: DataGridExportScope;
};
// ─── Callbacks ───────────────────────────────────────────────────────

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/js",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/next",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/react",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/sc",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"exports": {
"./force-react-server": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/shared-backend",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -5,4 +5,3 @@
// "@hexclave/shared-backend/config-agent" subpath)
export * from "./config-file";
export * from "./config-updater";

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/shared",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"scripts": {
"build": "rimraf dist && tsdown",

View File

@ -115,4 +115,3 @@ import.meta.vitest?.test("detectConfigImportPackage picks first matching package
expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined();
expect(detectConfigImportPackage([])).toBeUndefined();
});

View File

@ -1,7 +1,7 @@
{
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/tanstack-start",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -13,7 +13,7 @@
"//": "NEXT_LINE_PLATFORM template",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -2,7 +2,7 @@
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
"name": "@hexclave/template",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"sideEffects": false,
"main": "./dist/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/ui",
"version": "1.0.36",
"version": "1.0.37",
"repository": "https://github.com/hexclave/hexclave",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/swift-sdk",
"version": "1.0.36",
"version": "1.0.37",
"private": true,
"description": "Hexclave Swift SDK",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@hexclave/sdk-spec",
"version": "1.0.36",
"version": "1.0.37",
"private": true,
"description": "Hexclave SDK specification files",
"scripts": {}