Various improvements

This commit is contained in:
Konstantin Wohlwend
2026-07-13 13:40:56 -07:00
parent a66a8d972d
commit 770f01a057
13 changed files with 316 additions and 113 deletions
@@ -0,0 +1,5 @@
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
CREATE INDEX CONCURRENTLY IF NOT EXISTS "CliAuthAttempt_tenancyId_createdAt_id_idx"
ON /* SCHEMA_NAME_SENTINEL */."CliAuthAttempt"("tenancyId", "createdAt" DESC, "id" DESC);
@@ -0,0 +1,28 @@
import type { Sql } from "postgres";
import { expect } from "vitest";
export const postMigration = async (sql: Sql) => {
const indexes = await sql`
SELECT
pg_get_indexdef(index_relation.oid) AS indexdef,
index_metadata.indisvalid,
index_metadata.indisready
FROM pg_index index_metadata
JOIN pg_class index_relation
ON index_relation.oid = index_metadata.indexrelid
JOIN pg_class table_relation
ON table_relation.oid = index_metadata.indrelid
JOIN pg_namespace table_namespace
ON table_namespace.oid = table_relation.relnamespace
WHERE table_namespace.nspname = current_schema()
AND table_relation.relname = 'CliAuthAttempt'
AND index_relation.relname = 'CliAuthAttempt_tenancyId_createdAt_id_idx'
`;
expect(indexes).toHaveLength(1);
expect(indexes[0]).toMatchObject({
indisvalid: true,
indisready: true,
});
expect(indexes[0].indexdef).toContain('("tenancyId", "createdAt" DESC, id DESC)');
};
+1
View File
@@ -1140,6 +1140,7 @@ model CliAuthAttempt {
updatedAt DateTime @updatedAt
@@id([tenancyId, id])
@@index([tenancyId, createdAt(sort: Desc), id(sort: Desc)], name: "CliAuthAttempt_tenancyId_createdAt_id_idx")
}
model UserNotificationPreference {
@@ -3,11 +3,12 @@ import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClien
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
const recentAttemptLimit = 50;
const activeTokenAttemptLimit = 200;
type CliAuthAttemptRow = {
id: string,
pollingCode: string,
loginCode: string,
refreshToken: string | null,
status: "pending" | "completed" | "expired" | "used",
expiresAt: Date,
usedAt: Date | null,
createdAt: Date,
@@ -42,12 +43,14 @@ export const GET = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
summary: yupObject({
total_attempts: yupNumber().integer().defined(),
completed_attempts: yupNumber().integer().defined(),
used_attempts: yupNumber().integer().defined(),
expired_attempts: yupNumber().integer().defined(),
pending_attempts: yupNumber().integer().defined(),
active_tokens: yupNumber().integer().defined(),
attempts_in_window: yupNumber().integer().defined(),
completed_attempts_in_window: yupNumber().integer().defined(),
used_attempts_in_window: yupNumber().integer().defined(),
expired_attempts_in_window: yupNumber().integer().defined(),
pending_attempts_in_window: yupNumber().integer().defined(),
active_tokens_in_lookup_window: yupNumber().integer().defined(),
attempt_window_limit: yupNumber().integer().defined(),
active_token_lookup_window_limit: yupNumber().integer().defined(),
}).defined(),
recent_attempts: yupArray(yupObject({
id: yupString().defined(),
@@ -73,72 +76,72 @@ export const GET = createSmartRouteHandler({
const schema = await getPrismaSchemaForTenancy(tenancy);
const now = new Date();
// Fetch recent CLI auth attempts (last 50)
// These dashboard metrics intentionally describe a bounded recent window.
// Exact all-time aggregates would scan unbounded history on every page load.
const recentAttempts = await prisma.$replica().$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
SELECT
"id",
"pollingCode",
"loginCode",
"refreshToken",
CASE
WHEN "usedAt" IS NOT NULL THEN 'used'
WHEN "expiresAt" < ${now} THEN 'expired'
WHEN "refreshToken" IS NOT NULL THEN 'completed'
ELSE 'pending'
END AS "status",
"expiresAt",
"usedAt",
"createdAt"
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
WHERE "tenancyId" = ${tenancy.id}::UUID
ORDER BY "createdAt" DESC
LIMIT 50
ORDER BY "createdAt" DESC, "id" DESC
LIMIT ${recentAttemptLimit}
`);
// Compute summary stats
let used = 0;
let completed = 0;
let expired = 0;
let pending = 0;
for (const attempt of recentAttempts) {
if (attempt.usedAt != null) {
used++;
} else if (attempt.expiresAt < now) {
expired++;
} else if (attempt.refreshToken != null) {
completed++;
} else {
pending++;
switch (attempt.status) {
case "used": {
used++;
break;
}
case "completed": {
completed++;
break;
}
case "expired": {
expired++;
break;
}
case "pending": {
pending++;
break;
}
}
}
// Format recent attempts with status
const formattedAttempts = recentAttempts.map((attempt) => {
let status: "pending" | "completed" | "expired" | "used";
if (attempt.usedAt != null) {
status = "used";
} else if (attempt.refreshToken != null && attempt.expiresAt >= now) {
status = "completed";
} else if (attempt.expiresAt < now) {
status = "expired";
} else {
status = "pending";
}
return {
id: attempt.id,
status,
status: attempt.status,
created_at: attempt.createdAt.toISOString(),
expires_at: attempt.expiresAt.toISOString(),
used_at: attempt.usedAt?.toISOString() ?? null,
};
});
// Find active CLI tokens: tokens that were created via CLI auth (by looking
// at CliAuthAttempt rows that have a non-null refreshToken and usedAt).
// We join with the global ProjectUserRefreshToken table to find active sessions.
// Bounded to most recent 200 to avoid scanning entire history for high-usage tenants.
// Active-session discovery is also bounded: it considers refresh tokens
// attached to the most recently used CLI attempts, then checks which of
// those sessions still exist in the canonical refresh-token table.
const cliRefreshTokens = await prisma.$replica().$queryRaw<{ refreshToken: string }[]>(Prisma.sql`
SELECT "refreshToken"
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "refreshToken" IS NOT NULL
AND "usedAt" IS NOT NULL
ORDER BY "createdAt" DESC
LIMIT 200
ORDER BY "createdAt" DESC, "id" DESC
LIMIT ${activeTokenAttemptLimit}
`);
let activeCliUsers: Array<{
@@ -150,15 +153,12 @@ export const GET = createSmartRouteHandler({
expires_at: string | null,
is_expired: boolean,
}> = [];
let activeTokenCount = 0;
if (cliRefreshTokens.length > 0) {
const tokenValues = cliRefreshTokens.map((r) => r.refreshToken);
// Get accurate total count of active (non-expired) tokens separately from the display list
const tokenValues = cliRefreshTokens.map((row) => row.refreshToken);
const countResult = await globalPrismaClient.$replica().$queryRaw<{ count: bigint }[]>(Prisma.sql`
SELECT COUNT(*) as count
SELECT COUNT(*) AS count
FROM "ProjectUserRefreshToken"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "refreshToken" = ANY(${tokenValues})
@@ -166,7 +166,6 @@ export const GET = createSmartRouteHandler({
`);
activeTokenCount = Number(countResult[0]?.count ?? 0n);
// Look up which tokens are still active in the global refresh token table (limited for display)
const activeTokens = await globalPrismaClient.$replica().$queryRaw<ActiveCliTokenRow[]>(Prisma.sql`
SELECT
"id",
@@ -182,11 +181,7 @@ export const GET = createSmartRouteHandler({
`);
if (activeTokens.length > 0) {
// Fetch user info for the active tokens
const userIds = [...new Set(activeTokens.map((t) => t.projectUserId))];
// ProjectUser has no email column; the primary email lives in ContactChannel
// (type = EMAIL, isPrimary = TRUE). isPrimary/type are Postgres enums, so we
// cast to text for a schema-agnostic comparison.
const userIds = [...new Set(activeTokens.map((token) => token.projectUserId))];
const userRows = await prisma.$replica().$queryRaw<ProjectUserRow[]>(Prisma.sql`
SELECT
pu."projectUserId",
@@ -201,11 +196,10 @@ export const GET = createSmartRouteHandler({
WHERE pu."tenancyId" = ${tenancy.id}::UUID
AND pu."projectUserId" = ANY(${userIds}::UUID[])
`);
const userMap = new Map(userRows.map((u) => [u.projectUserId, u]));
const usersById = new Map(userRows.map((user) => [user.projectUserId, user]));
activeCliUsers = activeTokens.map((token) => {
const user = userMap.get(token.projectUserId);
const isExpired = token.expiresAt != null && token.expiresAt < now;
const user = usersById.get(token.projectUserId);
return {
user_id: token.projectUserId,
display_name: user?.displayName ?? null,
@@ -213,7 +207,7 @@ export const GET = createSmartRouteHandler({
token_created_at: token.createdAt.toISOString(),
last_active_at: token.lastActiveAt.toISOString(),
expires_at: token.expiresAt?.toISOString() ?? null,
is_expired: isExpired,
is_expired: token.expiresAt != null && token.expiresAt < now,
};
});
}
@@ -224,12 +218,14 @@ export const GET = createSmartRouteHandler({
bodyType: "json" as const,
body: {
summary: {
total_attempts: recentAttempts.length,
completed_attempts: completed,
used_attempts: used,
expired_attempts: expired,
pending_attempts: pending,
active_tokens: activeTokenCount,
attempts_in_window: recentAttempts.length,
completed_attempts_in_window: completed,
used_attempts_in_window: used,
expired_attempts_in_window: expired,
pending_attempts_in_window: pending,
active_tokens_in_lookup_window: activeTokenCount,
attempt_window_limit: recentAttemptLimit,
active_token_lookup_window_limit: activeTokenAttemptLimit,
},
recent_attempts: formattedAttempts,
active_cli_users: activeCliUsers,
@@ -1,6 +1,6 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { drainInFlightPromises } from "@/utils/background-tasks";
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
// Test/dev-only hook that awaits any in-flight background tasks spawned via
// `runAsynchronouslyAndWaitUntil` (e.g. Stripe webhook processing, which is
@@ -18,7 +18,11 @@ export const POST = createSmartRouteHandler({
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
tenancy: adaptSchema.defined(),
tenancy: yupObject({
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
@@ -24,12 +24,14 @@ import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
type CliAuthSummary = {
total_attempts: number,
completed_attempts: number,
used_attempts: number,
expired_attempts: number,
pending_attempts: number,
active_tokens: number,
attempts_in_window: number,
completed_attempts_in_window: number,
used_attempts_in_window: number,
expired_attempts_in_window: number,
pending_attempts_in_window: number,
active_tokens_in_lookup_window: number,
attempt_window_limit: number,
active_token_lookup_window_limit: number,
};
type CliAuthAttempt = {
@@ -96,7 +98,7 @@ function formatRelativeTime(dateStr: string): string {
export default function PageClient() {
return (
<AppEnabledGuard appId="cli-auth">
<PageLayout title="CLI Auth" description="Monitor CLI authentication sessions and active tokens">
<PageLayout title="CLI Auth" description="Monitor recent authentication attempts and CLI sessions">
<CliAuthContent />
</PageLayout>
</AppEnabledGuard>
@@ -138,7 +140,7 @@ function CliAuthContent() {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)}
{Array.from({ length: 5 }).map((_, index) => <Skeleton key={index} className="h-20 w-full rounded-xl" />)}
</div>
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
@@ -161,8 +163,8 @@ function CliAuthContent() {
function CliAuthDashboard({ data }: { data: CliAuthData }) {
const { summary, recent_attempts, active_cli_users } = data;
const activeUsers = active_cli_users.filter((u) => !u.is_expired);
const expiredUsers = active_cli_users.filter((u) => u.is_expired);
const activeUsers = active_cli_users.filter((user) => !user.is_expired);
const expiredUsers = active_cli_users.filter((user) => user.is_expired);
return (
<div className="flex flex-col gap-4">
@@ -172,28 +174,28 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
description={<>
CLI Auth allows users to authenticate from command-line tools using a browser-based login flow.
The CLI initiates a session, the user confirms in a browser, and a refresh token is issued to the CLI.
Metrics below are bounded snapshots: attempt counts cover the newest {summary.attempt_window_limit} attempts,
and active sessions are discovered from the newest {summary.active_token_lookup_window_limit} used attempts.
</>}
/>
{/* Summary KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<KpiCard label="Total" value={summary.total_attempts} icon={<TerminalWindowIcon className="h-4 w-4" />} />
<KpiCard label="Used" value={summary.used_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-emerald-500" />} />
<KpiCard label="Ready" value={summary.completed_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-blue-500" />} />
<KpiCard label="Expired" value={summary.expired_attempts} icon={<XCircleIcon className="h-4 w-4 text-red-500" />} />
<KpiCard label="Active tokens" value={summary.active_tokens} icon={<UserIcon className="h-4 w-4 text-blue-500" />} />
<KpiCard label="Recent attempts" value={summary.attempts_in_window} icon={<TerminalWindowIcon className="h-4 w-4" />} />
<KpiCard label="Used" value={summary.used_attempts_in_window} icon={<CheckCircleIcon className="h-4 w-4 text-emerald-500" />} />
<KpiCard label="Ready" value={summary.completed_attempts_in_window} icon={<CheckCircleIcon className="h-4 w-4 text-blue-500" />} />
<KpiCard label="Expired" value={summary.expired_attempts_in_window} icon={<XCircleIcon className="h-4 w-4 text-red-500" />} />
<KpiCard label="Active tokens" value={summary.active_tokens_in_lookup_window} icon={<UserIcon className="h-4 w-4 text-blue-500" />} />
</div>
{/* Active CLI Users */}
<DesignCard
title="Active CLI Sessions"
subtitle={`${activeUsers.length} user${activeUsers.length === 1 ? "" : "s"} with active CLI refresh tokens`}
subtitle={`${summary.active_tokens_in_lookup_window} active token${summary.active_tokens_in_lookup_window === 1 ? "" : "s"} found in the latest ${summary.active_token_lookup_window_limit} used attempts`}
icon={UserIcon}
glassmorphic
>
{activeUsers.length === 0 ? (
<div className="py-6 text-center">
<Typography variant="secondary" className="text-xs">No active CLI sessions.</Typography>
<Typography variant="secondary" className="text-xs">No active CLI sessions in the lookup window.</Typography>
</div>
) : (
<div className="divide-y divide-border/40">
@@ -203,7 +205,7 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
<Typography className="text-sm font-medium truncate">
{user.display_name ?? user.primary_email ?? user.user_id}
</Typography>
{user.primary_email && user.display_name && (
{user.primary_email != null && user.display_name != null && (
<Typography variant="secondary" className="text-xs truncate">{user.primary_email}</Typography>
)}
</div>
@@ -224,17 +226,15 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
)}
{expiredUsers.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors hover:transition-none">
{expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"}
<summary className="cursor-pointer text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground hover:transition-none">
{expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"} in the lookup window
</summary>
<div className="mt-2 divide-y divide-border/40">
{expiredUsers.map((user) => (
<div key={`${user.user_id}-${user.token_created_at}`} className="flex items-center justify-between gap-3 py-2.5 opacity-60">
<div className="flex flex-col gap-0.5 min-w-0">
<Typography className="text-sm truncate">
{user.display_name ?? user.primary_email ?? user.user_id}
</Typography>
</div>
<Typography className="text-sm truncate">
{user.display_name ?? user.primary_email ?? user.user_id}
</Typography>
<div className="flex items-center gap-3 shrink-0">
<Typography variant="secondary" className="text-[11px]">
Expired {user.expires_at != null ? formatRelativeTime(user.expires_at) : ""}
@@ -248,7 +248,6 @@ function CliAuthDashboard({ data }: { data: CliAuthData }) {
)}
</DesignCard>
{/* Recent Attempts */}
<DesignCard
title="Recent Login Attempts"
subtitle="Last 50 CLI authentication attempts"
+6 -4
View File
@@ -1794,10 +1794,12 @@ export namespace Payments {
// Waits for any in-flight background tasks (e.g. async Stripe webhook processing)
// to finish. Backed by the internal flush-background-tasks endpoint.
export async function flushBackgroundTasks() {
const res = await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
method: "POST",
accessType: "admin",
body: {},
const res = await withInternalProject(async () => {
return await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
method: "POST",
accessType: "admin",
body: {},
});
});
expect(res.status).toBe(200);
}
@@ -0,0 +1,41 @@
import { it } from "../../../../../helpers";
import { Project, niceBackendFetch } from "../../../../backend-helpers";
it("returns bounded CLI authentication metrics without exposing login secrets", async ({ expect }) => {
await Project.createAndSwitch();
const createdAttempt = await niceBackendFetch("/api/latest/auth/cli", {
method: "POST",
accessType: "server",
body: {},
});
expect(createdAttempt.status).toBe(200);
const response = await niceBackendFetch("/api/latest/internal/cli-auth", {
method: "GET",
accessType: "admin",
});
expect(response.status).toBe(200);
expect(response.body.summary).toEqual({
attempts_in_window: 1,
completed_attempts_in_window: 0,
used_attempts_in_window: 0,
expired_attempts_in_window: 0,
pending_attempts_in_window: 1,
active_tokens_in_lookup_window: 0,
attempt_window_limit: 50,
active_token_lookup_window_limit: 200,
});
expect(response.body.recent_attempts).toHaveLength(1);
expect(response.body.recent_attempts[0]).toMatchObject({
status: "pending",
used_at: null,
});
expect(response.body.recent_attempts[0].id).toEqual(expect.any(String));
expect(response.body.recent_attempts[0].created_at).toEqual(expect.any(String));
expect(response.body.recent_attempts[0].expires_at).toEqual(expect.any(String));
expect(response.body.active_cli_users).toEqual([]);
expect(JSON.stringify(response.body)).not.toContain(createdAttempt.body.polling_code);
expect(JSON.stringify(response.body)).not.toContain(createdAttempt.body.login_code);
});
@@ -0,0 +1,33 @@
import { it } from "../../../../../helpers";
import { Project, niceBackendFetch, withInternalProject } from "../../../../backend-helpers";
it("rejects a non-internal project", async ({ expect }) => {
await Project.createAndSwitch();
const response = await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
method: "POST",
accessType: "admin",
body: {},
});
expect(response.status).toBe(400);
expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR");
});
it("accepts the internal project admin key", async ({ expect }) => {
const response = await withInternalProject(async () => {
return await niceBackendFetch("/api/latest/internal/flush-background-tasks", {
method: "POST",
accessType: "admin",
body: {},
});
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
});
@@ -1007,7 +1007,7 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
}
\`\`\`
\`hexclave dev\` injects all necessary environment variables into the app process automatically, so the app is ready to use without any extra environment variable setup.${isAiPrompt ? " It injects non-sensitive environment variables (eg. the project ID) with and without the prefixes \`NEXT_PUBLIC_\` and \`VITE_\`, so no extra environment variable setup is necessary for most frameworks." : ""}
\`hexclave dev\` injects all necessary environment variables into the app process automatically, so the app is ready to use without any extra environment variable setup.${isAiPrompt ? " It injects non-sensitive environment variables (eg. the project ID) with and without the prefixes \`NEXT_PUBLIC_\` and \`VITE_\`, so no extra environment variable setup is necessary for most frameworks. Do not run `npm run dev:inner`, as that will skip past the Hexclave server." : ""}
</Accordion>
<Accordion title="Option 2: Connecting to a production project hosted in the cloud">
@@ -8,6 +8,7 @@ import { ReactPromise, runAsynchronously } from "@hexclave/shared/dist/utils/pro
import { suspendIfSsr, use } from "@hexclave/shared/dist/utils/react";
import { Result } from "@hexclave/shared/dist/utils/results";
import { Store } from "@hexclave/shared/dist/utils/stores";
import { deindent } from "@hexclave/shared/dist/utils/strings";
import { getDefaultApiUrls } from "@hexclave/shared/dist/utils/urls";
import React, { useCallback } from "react"; // THIS_LINE_PLATFORM react-like
import { envVars } from "../../../../generated/env";
@@ -40,7 +41,23 @@ const showMissingConfigAlertInBrowser = (message: string) => {
};
const throwMissingProjectIdError = (): never => {
const message = "Welcome to Hexclave! It seems that you haven't provided a project ID. Please create a project on the Hexclave dashboard at https://app.hexclave.com and put it in the HEXCLAVE_PROJECT_ID environment variable.";
const message = deindent`
Welcome to Hexclave!
It seems that you haven't provided a project ID.
${envVars.NODE_ENV?.includes("dev") ? deindent`
You are running this app in development mode. Make sure that your start command is wrapped in a call to the Hexclave CLI: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
Alternatively, you can also connect to a Cloud project's environment variables instead by creating a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
` : envVars.NODE_ENV?.includes("prod") ? deindent`
It seems you are running a project in production. Please create a project on the Hexclave dashboard at https://app.hexclave.com and put it in the HEXCLAVE_PROJECT_ID environment variable.
` : deindent`
- If you are running a development project and would like to connect the local Hexclave dashboard, wrap your start command in: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
- If you are running a project in production and would like to connect to Hexclave Cloud, create a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
`}
`;
showMissingConfigAlertInBrowser(message);
return throwErr(new Error(message));
};
@@ -369,7 +369,7 @@ export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () =>
return;
}
const issueMessage = issue.messages.join("\n");
const issueMessage = "Hexclave config " + issue.kind + ": " + issue.messages.join("\n");
const issueKey = `${app.projectId}:${issue.kind}:${issueMessage}`;
const issueLabel = issue.kind === "error" ? "error" : "warning";
const issueTitle = issue.kind === "error"
+90 -13
View File
@@ -15,29 +15,106 @@ PORT_PREFIX="${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}"
BULLDOZER_PORT="${PORT_PREFIX}46"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BULLDOZER_DIR="$REPO_ROOT/apps/bulldozer-js"
DATA_DIR="$BULLDOZER_DIR/.data"
TEMP_DATA_DIR=""
BACKUP_DATA_DIR="$BULLDOZER_DIR/.data-backup.untracked.$$"
BULLDOZER_PID=""
echo "Wiping bulldozer-js LMDB store at $BULLDOZER_DIR/.data ..."
rm -rf "$BULLDOZER_DIR/.data"
port_is_open() {
node - "$BULLDOZER_PORT" <<'NODE'
const net = require("node:net");
const port = Number(process.argv[2]);
const socket = net.createConnection({ host: "127.0.0.1", port });
socket.setTimeout(250);
socket.once("connect", () => {
socket.destroy();
process.exit(0);
});
socket.once("error", () => process.exit(1));
socket.once("timeout", () => {
socket.destroy();
process.exit(1);
});
NODE
}
echo "Starting temporary bulldozer-js on port $BULLDOZER_PORT ..."
# Run the server directly (single node process, so we can reliably kill it) via
# tsx's node loader — the same entrypoint the package's `start` script uses.
(
cd "$BULLDOZER_DIR" && NODE_ENV=development exec node --import tsx --expose-gc src/index.ts
) &
BULLDOZER_PID=$!
cleanup() {
stop_bulldozer() {
if [[ -z "$BULLDOZER_PID" ]]; then
return
fi
echo "Shutting down temporary bulldozer-js (pid $BULLDOZER_PID) ..."
kill "$BULLDOZER_PID" 2>/dev/null || true
wait "$BULLDOZER_PID" 2>/dev/null || true
BULLDOZER_PID=""
}
cleanup() {
stop_bulldozer
if [[ -n "$TEMP_DATA_DIR" && -d "$TEMP_DATA_DIR" ]]; then
rm -rf "$TEMP_DATA_DIR"
fi
if [[ -e "$BACKUP_DATA_DIR" ]]; then
if [[ ! -e "$DATA_DIR" ]]; then
mv "$BACKUP_DATA_DIR" "$DATA_DIR"
else
rm -rf "$BACKUP_DATA_DIR"
fi
fi
}
trap cleanup EXIT
echo "Waiting for bulldozer-js to accept connections on port $BULLDOZER_PORT ..."
pnpm exec wait-on -t 60000 "tcp:localhost:$BULLDOZER_PORT"
if port_is_open; then
echo "Cannot re-seed bulldozer-js: port $BULLDOZER_PORT is already in use." >&2
echo "Stop the existing bulldozer-js process and run restart-deps again." >&2
exit 1
fi
# Build the replacement store separately. The existing store remains untouched
# unless startup and the full Postgres backfill both succeed.
TEMP_DATA_DIR="$(mktemp -d "$BULLDOZER_DIR/.data-reseed.untracked.XXXXXX")"
TEMP_LMDB_PATH="$TEMP_DATA_DIR/bulldozer-js-lmdb"
echo "Starting temporary bulldozer-js on port $BULLDOZER_PORT ..."
(
cd "$BULLDOZER_DIR" && \
NODE_ENV=development \
HEXCLAVE_BULLDOZER_JS_LMDB_PATH="$TEMP_LMDB_PATH" \
exec node --import tsx --expose-gc src/index.ts
) &
BULLDOZER_PID=$!
echo "Waiting for temporary bulldozer-js to accept connections on port $BULLDOZER_PORT ..."
ready=0
for ((attempt = 0; attempt < 120; attempt++)); do
if ! kill -0 "$BULLDOZER_PID" 2>/dev/null; then
process_status=0
wait "$BULLDOZER_PID" || process_status=$?
BULLDOZER_PID=""
echo "Temporary bulldozer-js exited before becoming ready (status $process_status)." >&2
exit 1
fi
if port_is_open; then
ready=1
break
fi
sleep 0.5
done
if [[ "$ready" -ne 1 ]]; then
echo "Temporary bulldozer-js did not become ready within 60 seconds." >&2
exit 1
fi
echo "Running Postgres->bulldozer backfill ..."
pnpm run db:backfill-bulldozer-from-prisma
stop_bulldozer
echo "Replacing bulldozer-js LMDB store at $DATA_DIR ..."
if [[ -e "$DATA_DIR" ]]; then
mv "$DATA_DIR" "$BACKUP_DATA_DIR"
fi
mv "$TEMP_DATA_DIR" "$DATA_DIR"
TEMP_DATA_DIR=""
rm -rf "$BACKUP_DATA_DIR"
echo "Bulldozer re-seed complete."