Skip to content
Merged
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
10 changes: 8 additions & 2 deletions agents/db/src/collection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/cloudflare';
import { getAgentByName, type AgentNamespace } from 'agents';
import { count, eq } from 'drizzle-orm';
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
import { z } from 'zod';
Expand Down Expand Up @@ -1305,8 +1306,13 @@ export class DbCollection extends LiveShard {
}

private async parentStub(projectId: string) {
const namespace = this.env.DbAgent as unknown as DurableObjectNamespace<DbAgent>;
return namespace.get(namespace.idFromName(projectId));
const namespace = this.env.DbAgent as unknown as AgentNamespace<DbAgent>;
// getAgentByName, never a raw namespace stub: plain RPC bypasses the
// SDK's fetch-path initialization, so a cold parent would serve the
// config pull before onStart's migrations and every first REST touch
// of a fresh project would 503 ("no such table: collections").
// getAgentByName synchronizes on onStart before returning the stub.
return getAgentByName(namespace, projectId);
}

protected getVerifier(): ProjectJwtVerifier {
Expand Down
16 changes: 10 additions & 6 deletions agents/db/src/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DurableObject } from 'cloudflare:workers';
import { getAgentByName, type AgentNamespace } from 'agents';
import { count, eq, and } from 'drizzle-orm';
import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
Expand Down Expand Up @@ -388,9 +389,12 @@ export class DbGateway extends DurableObject<Env> {
// -------------------------------------------------------------------------
// Parent plumbing (routing, origins, socket-pressure reports)

private parentStub() {
const namespace = this.env.DbAgent as unknown as DurableObjectNamespace<DbAgent>;
return namespace.get(namespace.idFromName(this.projectId));
private async parentStub() {
const namespace = this.env.DbAgent as unknown as AgentNamespace<DbAgent>;
// getAgentByName, never a raw namespace stub - see the twin comment in
// collection.ts: a raw RPC can reach a cold parent before onStart's
// migrations and turn every first touch into a 503.
return getAgentByName(namespace, this.projectId);
}

private routingTtl(): number {
Expand All @@ -403,7 +407,7 @@ export class DbGateway extends DurableObject<Env> {
if (cached && cached.expires > now) return cached;
let entry: ShardRoutingEntry = { kind: null, auto: false, expires: now + this.routingTtl() };
try {
const routing = await this.parentStub().getShardRouting(name);
const routing = await (await this.parentStub()).getShardRouting(name);
if (routing) {
entry = {
kind: routing.kind,
Expand Down Expand Up @@ -433,7 +437,7 @@ export class DbGateway extends DurableObject<Env> {
if (this.origins && this.origins.expires > now) return this.origins.allowed;
let allowed: string[] = [];
try {
allowed = await this.parentStub().getAllowedOrigins();
allowed = await (await this.parentStub()).getAllowedOrigins();
} catch {
// Fail open to the environment allowlist only - a broken parent
// link must not take realtime down for same-origin clients.
Expand All @@ -457,7 +461,7 @@ export class DbGateway extends DurableObject<Env> {
return;
}
try {
await this.parentStub().reportGatewaySockets(this.gatewayId, this.region, sockets);
await (await this.parentStub()).reportGatewaySockets(this.gatewayId, this.region, sockets);
this.lastReportedSockets = sockets;
} catch {
this.lastReportedSockets = null;
Expand Down
8 changes: 6 additions & 2 deletions agents/db/src/table.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/cloudflare';
import { getAgentByName, type AgentNamespace } from 'agents';
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
import { z } from 'zod';
import migrations from './migrations';
Expand Down Expand Up @@ -1600,8 +1601,11 @@ export class DbTable extends LiveShard {
}

private async parentStub(projectId: string) {
const namespace = this.env.DbAgent as unknown as DurableObjectNamespace<DbAgent>;
return namespace.get(namespace.idFromName(projectId));
const namespace = this.env.DbAgent as unknown as AgentNamespace<DbAgent>;
// getAgentByName, never a raw namespace stub - see the twin comment in
// collection.ts: a raw RPC can reach a cold parent before onStart's
// migrations and turn every first touch into a 503.
return getAgentByName(namespace, projectId);
}

protected getVerifier(): ProjectJwtVerifier {
Expand Down
21 changes: 21 additions & 0 deletions e2e/demo-project.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ test.describe('demo project', () => {
expect(settings.ok(), await settings.text()).toBeTruthy();
});

test('the first REST touch of a brand-new project auto-creates, never 503s', async ({
request
}) => {
// Regression: the collection's lazy config pull used a raw namespace
// stub, which bypasses the SDK's fetch-path initialization - so a parent
// DbAgent that had never been woken over HTTP had no schema yet, the
// pull died on "no such table: collections", and EVERY first REST touch
// of a fresh project answered a persistent 503. Every other test here
// provisions through the admin surface first (which wakes the parent
// over HTTP), which is exactly why this path went uncovered. A unique
// id per run keeps this a genuine first touch.
const fresh = `demo-${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`;
const write = await request.post(dbDocumentsPath(fresh, 'posts'), {
data: { data: { title: 'first touch' } }
});
// Default collection access is 'auth', so the correct answer to an
// anonymous write is an auth refusal - which proves the config pull and
// the auto-create both ran. The bug's signature is a 503.
expect(write.status(), await write.text()).toBe(401);
});

test('serves the demo database flow without an operator', async ({ request }) => {
// The Database snippets must run for the anonymous visitor too: the demo
// bypass covers the admin surface (creating the collection), and the
Expand Down
20 changes: 20 additions & 0 deletions e2e/landing.ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ test.describe('landing page (frontend)', () => {
await expect(page.getByText('AuthAgent · DO SQLite')).toBeVisible();
});

test('the install section offers the CLI command and the agent prompt', async ({
page,
request
}) => {
await page.goto('/');

await expect(page.getByRole('heading', { name: 'Or let your agent do it' })).toBeVisible();
await expect(page.getByTestId('install-command')).toContainText(
'npx @frostbase-dev/cli init my-backend'
);
await expect(page.getByTestId('agent-prompt')).toContainText(
'Read frostbase.dev/agents.md and set up Frostbase in this project'
);

// The prompt is only honest if the file it points at is actually served.
const response = await request.get('/agents.md');
expect(response.status()).toBe(200);
expect(await response.text()).toContain('# Set up Frostbase');
});

test('the pricing story and comparison table state the trade-offs', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /Our price/ })).toBeVisible();
Expand Down
103 changes: 103 additions & 0 deletions src/routes/(marketing)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
X,
ArrowRight,
Check,
Copy,
ChevronDown,
Database,
GitFork,
Expand Down Expand Up @@ -58,6 +59,26 @@
'Analytics Engine'
];

// The install section's two one-liners: the CLI command and its
// agent-shaped twin, which points at /agents.md (static/agents.md) - the
// setup instructions written for coding agents, throwaway demo path
// included. Copy state is per box so one click never flips the other icon.
const installCommand = 'npx @frostbase-dev/cli init my-backend';
const agentPrompt = 'Read frostbase.dev/agents.md and set up Frostbase in this project';
const agentNames = ['Claude Code', 'Codex', 'Gemini CLI', 'OpenCode', 'Cursor'];
let copiedInstall = $state<'cli' | 'agent' | null>(null);
let copyInstallTimer: ReturnType<typeof setTimeout> | undefined;
async function copyInstall(kind: 'cli' | 'agent', text: string) {
try {
await navigator.clipboard.writeText(text);
copiedInstall = kind;
clearTimeout(copyInstallTimer);
copyInstallTimer = setTimeout(() => (copiedInstall = null), 1500);
} catch {
// clipboard unavailable - the text stays selectable
}
}

// The comparison matrix. Cells are a rating plus a short receipt - never a
// bare checkmark. Competitor cells stay qualitative on purpose: exact
// prices live once, with dated sources, on /pricing.
Expand Down Expand Up @@ -765,6 +786,88 @@
</div>
</section>

<!-- INSTALL: the CLI one-liner and its agent-shaped twin, Deno-style.
The boxes stay zinc-950 in both themes, matching every other
terminal block on this page. -->
<section id="install" class="px-4 pb-16 sm:px-8 md:pb-28">
<div class="mx-auto max-w-3xl">
<div class="text-center">
<span
class="inline-flex items-center rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 text-xs font-medium tracking-wide text-primary uppercase"
>Get started</span
>
<h2 class="mt-4 text-3xl font-bold md:text-4xl">A backend in one command.</h2>
<p class="mx-auto mt-3 max-w-xl text-muted-foreground">
Scaffold a Worker with auth installed, add the database, deploy to your own Cloudflare
account.
</p>
</div>

<div
class="relative mt-8 flex items-center gap-3 overflow-hidden rounded-xl border border-border bg-zinc-950 px-5 py-4"
>
<code
class="min-w-0 flex-1 overflow-x-auto font-mono text-sm whitespace-nowrap text-zinc-100"
data-testid="install-command"><span class="text-primary">$</span> {installCommand}</code
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 flex-shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy install command"
data-testid="copy-install-command"
onclick={() => copyInstall('cli', installCommand)}
>
{#if copiedInstall === 'cli'}<Check class="h-3.5 w-3.5" />{:else}<Copy
class="h-3.5 w-3.5"
/>{/if}
</Button>
</div>

<div class="mt-14 text-center">
<h3 class="text-2xl font-bold md:text-3xl">Or let your agent do it</h3>
<p class="mt-2 text-sm text-muted-foreground">Paste this into any coding agent.</p>
</div>

<div class="mt-6 overflow-hidden rounded-xl border border-border">
<div
class="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-border bg-card px-5 py-2.5 font-mono text-xs text-muted-foreground"
>
{#each agentNames as name (name)}
<span>{name}</span>
<span class="text-muted-foreground/40" aria-hidden="true">·</span>
{/each}
<span class="text-muted-foreground/60">&lt;your agent&gt;</span>
</div>
<div class="relative flex items-center gap-3 bg-zinc-950 px-5 py-4">
<code class="min-w-0 flex-1 font-mono text-sm text-zinc-100" data-testid="agent-prompt"
><span class="text-primary">&gt;</span> {agentPrompt}</code
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 flex-shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy agent prompt"
data-testid="copy-agent-prompt"
onclick={() => copyInstall('agent', agentPrompt)}
>
{#if copiedInstall === 'agent'}<Check class="h-3.5 w-3.5" />{:else}<Copy
class="h-3.5 w-3.5"
/>{/if}
</Button>
</div>
</div>
<p class="mt-3 text-center text-xs text-muted-foreground/70">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- static asset, not a route -->
<a href="/agents.md" class="underline underline-offset-2 hover:text-foreground"
>agents.md</a
>
includes a zero-account path: the agent builds on a throwaway demo project and hands you the
live dashboard.
</p>
</div>
</section>

<!-- PRICING: the real /pricing calculator, embedded - straight above
the API section on purpose, with /pricing's own hero copy. -->
<section id="pricing" class="border-y border-border bg-card px-4 py-16 sm:px-8 sm:py-24">
Expand Down
Loading
Loading