Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/add-manual-schema-reload-ui.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: added
body: Add a Lexicons page action and docs so operators can reload the public GraphQL schema after lexicon changes without restarting the backend.
custom:
Affects: operator
4 changes: 4 additions & 0 deletions .changes/unreleased/reject-invalid-admin-lexicons.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: changed
body: Change admin lexicon upload and registration so invalid lexicon documents are rejected before storage, preventing bad lexicons from breaking later public schema reloads.
custom:
Affects: operator
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
- This repo contains two codebases:
- the **Go backend** at the repo root
- the **Next.js frontend** in `client/`
- Use `bd` for task tracking. Run `bd onboard` if you need the repo-local workflow.

## Key boundaries and entrypoints

Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,18 @@ Lexicons define the AT Protocol record types you want to index. Hyperindex suppo

Or place lexicon JSON files in a directory and set the `LEXICON_DIR` environment variable.

After registering by NSID or uploading a ZIP file, restart/redeploy the backend indexer for the new lexicons to appear in the public GraphQL schema and query list. The admin lexicon list updates immediately, but typed GraphQL queries are generated at backend startup.
After registering, uploading, deleting, or re-adding lexicons, run the `reloadSchema` admin mutation or click **Reload schema** on the Lexicons page. This rebuilds the public `/graphql` schema in-place so typed fields appear or disappear without restarting the backend. If reload fails, Hyperindex keeps serving the previous working public schema and reports the lexicon error to fix. Schema reload does not change Tap/Jetstream ingestion filters; configure ingestion filters separately.

```graphql
mutation ReloadSchema {
reloadSchema {
success
lexiconCount
reloadedAt
error
}
}
```

**Example lexicons:**
- `org.hypercerts.claim.activity` - Hypercert claim activity
Expand Down Expand Up @@ -164,7 +175,7 @@ mutation {

Access your indexed data at `/graphql`:

Typed GraphQL query field names are generated from lexicon NSIDs. For example, `org.hypercerts.claim.activity` becomes `orgHypercertsClaimActivity`. Newly registered or uploaded lexicons appear in these typed queries after the backend indexer restarts.
Typed GraphQL query field names are generated from lexicon NSIDs. For example, `org.hypercerts.claim.activity` becomes `orgHypercertsClaimActivity`. Newly registered, uploaded, deleted, or re-added lexicons appear in these typed queries after you run `reloadSchema` or click **Reload schema** on the Lexicons page.

```graphql
# Generic query — all records by collection
Expand Down Expand Up @@ -416,6 +427,7 @@ The admin API at `/admin/graphql` provides:
**Mutations:**
- `uploadLexicons` - Register new lexicons
- `deleteLexicon` - Remove a lexicon
- `reloadSchema` - Rebuild the live public GraphQL schema after lexicon changes without restarting the backend
- `backfillActor` - Backfill a specific user
- `triggerBackfill` - Full network backfill
- `populateActivity` - Populate activity from existing records
Expand Down
2 changes: 1 addition & 1 deletion client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next

Vercel preview deployments run the frontend only. The app proxies GraphQL requests to the backend configured by `HYPERINDEX_URL` or `NEXT_PUBLIC_HYPERINDEX_URL`.

If a preview branch targets a shared backend such as `dev.api.hi.gainforest.app`, redeploying the Vercel preview does not rebuild the backend GraphQL schema. After registering or uploading lexicons, restart/redeploy the backend indexer that the preview points to before expecting new typed GraphQL query fields to appear.
If a preview branch targets a shared backend such as `dev.api.hi.gainforest.app`, redeploying the Vercel preview only updates the frontend. After registering, uploading, deleting, or re-adding lexicons, run the backend `reloadSchema` admin mutation or click **Reload schema** on the Lexicons page before expecting new typed GraphQL query fields to appear. If reload fails, the backend keeps serving the previous working public schema; Tap/Jetstream ingestion filters are configured separately.

See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for general Vercel deployment details.
58 changes: 56 additions & 2 deletions client/src/app/lexicons/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { useState, useMemo, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { graphqlClient } from "@/lib/graphql/client";
import { GET_LEXICONS } from "@/lib/graphql/queries";
import { REGISTER_LEXICON, DELETE_LEXICON, UPLOAD_LEXICONS } from "@/lib/graphql/mutations";
import { REGISTER_LEXICON, DELETE_LEXICON, UPLOAD_LEXICONS, RELOAD_SCHEMA } from "@/lib/graphql/mutations";
import {
formatReloadSchemaFailure,
formatReloadSchemaSuccess,
type ReloadSchemaResponse,
} from "@/lib/graphql/schema-reload";
import { useAdminSession } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Alert } from "@/components/ui/Alert";
Expand Down Expand Up @@ -292,6 +297,31 @@ export default function LexiconsPage() {
onSettled: () => setZipUploading(false),
});

const reloadSchemaMutation = useMutation({
mutationFn: () => {
if (!isAdmin) {
throw new Error("Admin access is required to reload the public GraphQL schema.");
}

return graphqlClient.request<ReloadSchemaResponse>(RELOAD_SCHEMA);
},
onSuccess: (response) => {
if (response.reloadSchema.success) {
setSuccess(formatReloadSchemaSuccess(response.reloadSchema.lexiconCount));
setError(null);
setTimeout(() => setSuccess(null), 5000);
return;
}

setError(formatReloadSchemaFailure(response.reloadSchema));
setSuccess(null);
},
onError: (err: Error) => {
setError(`Could not start schema reload: ${err.message}`);
setSuccess(null);
},
});

const deleteMutation = useMutation({
mutationFn: (nsid: string) =>
graphqlClient.request(DELETE_LEXICON, { nsid }),
Expand Down Expand Up @@ -442,6 +472,7 @@ export default function LexiconsPage() {
const roots = Array.from(tree.entries()).sort(([a], [b]) => a.localeCompare(b));
const isConfirmDeleting = confirmNsid !== null && confirmNsid === deletingNsid;
const isZipUploadPending = zipUploading || uploadMutation.isPending;
const isSchemaReloadPending = reloadSchemaMutation.isPending;

if (fetchError) {
return (
Expand Down Expand Up @@ -521,7 +552,7 @@ export default function LexiconsPage() {
</h3>
<div className="mt-1 space-y-1 text-xs" style={{ color: "var(--muted-foreground)" }}>
<p>Upload a .zip containing one or more lexicon .json files. Lexicons do not need to be published yet.</p>
<p>Each JSON file must contain a top-level id field. A backend restart may be required before new lexicons appear in the public GraphQL schema.</p>
<p>Each JSON file must contain a top-level id field. After changing lexicons, use Reload schema below to update the live public GraphQL schema.</p>
</div>
<form onSubmit={handleUpload} className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<label htmlFor="lexicon-zip-file" className="sr-only">
Expand Down Expand Up @@ -553,6 +584,29 @@ export default function LexiconsPage() {
)}
</section>
)}

{isAdmin && (
<section className="rounded-xl border p-4" style={{ backgroundColor: "var(--card)", borderColor: "var(--border)" }}>
<h3 className="text-sm font-semibold" style={{ color: "var(--foreground)" }}>
Public GraphQL schema
</h3>
<div className="mt-1 space-y-1 text-xs" style={{ color: "var(--muted-foreground)" }}>
<p>Registering, uploading, deleting, or re-adding lexicons updates the database list. Reload the live public /graphql schema to make typed fields appear or disappear without restarting the backend.</p>
<p>Reloading the schema does not update Tap/Jetstream ingestion filters. Configure ingestion filters separately.</p>
</div>
<div className="mt-4">
<Button
type="button"
variant="primary"
loading={isSchemaReloadPending}
disabled={isSchemaReloadPending}
onClick={() => reloadSchemaMutation.mutate()}
>
{isSchemaReloadPending ? "Reloading..." : "Reload schema"}
</Button>
</div>
</section>
)}
</div>

{/* Search */}
Expand Down
4 changes: 2 additions & 2 deletions client/src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,8 @@ export default function OnboardingPage() {
</div>
)}
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
You can skip this step and upload lexicons later. Your AppView will only
index records matching your installed lexicons.
You can skip this step and upload lexicons later. After adding lexicons,
reload the public GraphQL schema from the Lexicons page so typed fields appear without a backend restart.
</p>
</div>
</div>
Expand Down
14 changes: 14 additions & 0 deletions client/src/lib/graphql/mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";

import { RELOAD_SCHEMA } from "./mutations";

describe("RELOAD_SCHEMA", () => {
it("requests the reloadSchema result fields used by the Lexicons page", () => {
expect(RELOAD_SCHEMA).toContain("mutation ReloadSchema");
expect(RELOAD_SCHEMA).toContain("reloadSchema");
expect(RELOAD_SCHEMA).toContain("success");
expect(RELOAD_SCHEMA).toContain("lexiconCount");
expect(RELOAD_SCHEMA).toContain("reloadedAt");
expect(RELOAD_SCHEMA).toContain("error");
});
});
12 changes: 12 additions & 0 deletions client/src/lib/graphql/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ export const UPLOAD_LEXICONS = gql`
}
`;

// RELOAD_SCHEMA rebuilds the live public /graphql schema from current lexicon sources without restarting the backend.
export const RELOAD_SCHEMA = gql`
mutation ReloadSchema {
reloadSchema {
success
lexiconCount
reloadedAt
error
}
}
`;

// Reset All
export const RESET_ALL = gql`
mutation ResetAll($confirm: String!) {
Expand Down
59 changes: 59 additions & 0 deletions client/src/lib/graphql/schema-reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";

import {
formatReloadSchemaFailure,
formatReloadSchemaSuccess,
type ReloadSchemaResult,
} from "./schema-reload";

function reloadResult(overrides: Partial<ReloadSchemaResult>): ReloadSchemaResult {
return {
success: false,
lexiconCount: 0,
reloadedAt: null,
error: null,
...overrides,
};
}

describe("formatReloadSchemaSuccess", () => {
it("formats singular and plural active schema counts", () => {
expect(formatReloadSchemaSuccess(1)).toBe("Reloaded public schema with 1 lexicon.");
expect(formatReloadSchemaSuccess(42)).toBe("Reloaded public schema with 42 lexicons.");
});
});

describe("formatReloadSchemaFailure", () => {
it("explains fallback to the previous active schema count", () => {
const message = formatReloadSchemaFailure(
reloadResult({
lexiconCount: 2,
error: "parse database lexicon app.example.bad: invalid JSON",
}),
);

expect(message).toContain("parse database lexicon app.example.bad");
expect(message).toContain("Previous public schema is still active with 2 lexicons");
expect(message).toContain("active schema count, not the failed reload attempt");
});

it("uses neutral zero-count failure copy", () => {
const message = formatReloadSchemaFailure(
reloadResult({
lexiconCount: 0,
error: "parse filesystem lexicon bad.json: invalid JSON",
}),
);

expect(message).toContain("parse filesystem lexicon bad.json");
expect(message).toContain("active public schema currently has 0 lexicons");
expect(message).toContain("no previous public schema is active yet");
expect(message.toLowerCase()).toContain("fix the lexicon error and reload again");
});

it("uses a fallback error when the backend omits one", () => {
expect(formatReloadSchemaFailure(reloadResult({ error: " " }))).toContain(
"The backend did not return a reload error.",
);
});
});
41 changes: 41 additions & 0 deletions client/src/lib/graphql/schema-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Result returned by the admin reloadSchema mutation.
* `lexiconCount` is the currently active public schema count; on failure it is
* the previous working schema count. A zero count can mean either no schema is
* active yet or the previous active schema contains zero lexicons.
*/
export interface ReloadSchemaResult {
success: boolean;
lexiconCount: number;
reloadedAt: string | null;
error: string | null;
}

/** GraphQL response shape for the admin reloadSchema mutation. */
export interface ReloadSchemaResponse {
reloadSchema: ReloadSchemaResult;
}

/** Formats an operator-facing success message for a completed schema reload. */
export function formatReloadSchemaSuccess(lexiconCount: number): string {
return `Reloaded public schema with ${formatLexiconCount(lexiconCount)}.`;
}

/**
* Formats an operator-facing failure message for a reloadSchema payload failure.
* Payload failures mean the backend attempted reload, kept fallback behavior, and
* returned active-schema status in the normal GraphQL response.
*/
export function formatReloadSchemaFailure(result: ReloadSchemaResult): string {
const backendError = result.error?.trim() || "The backend did not return a reload error.";

if (result.lexiconCount > 0) {
return `Failed to reload public schema: ${backendError}. Previous public schema is still active with ${formatLexiconCount(result.lexiconCount)}. This is the active schema count, not the failed reload attempt.`;
}

return `Failed to reload public schema: ${backendError}. The active public schema currently has 0 lexicons, or no previous public schema is active yet. Fix the lexicon error and reload again.`;
}

function formatLexiconCount(count: number): string {
return `${count} lexicon${count === 1 ? "" : "s"}`;
}
Loading
Loading