feat: Step 6 - Write-through cache API

Implement the core write-through cache pattern for node creation.
This is the architectural foundation of the application.

Changes:
- Add @google/generative-ai dependency for embeddings
- Create lib/db.ts: SurrealDB connection helper with JWT auth
- Create lib/ai.ts: AI embedding generation using text-embedding-004
- Create app/api/nodes/route.ts: POST endpoint implementing write-through cache

Write-through cache flow:
1. Authenticate user via SurrealDB JWT
2. Publish node to ATproto PDS (source of truth)
3. Generate 768-dimensional embedding via Google AI
4. Cache node + embedding + links in SurrealDB

Updated schema to use 768-dimensional embeddings (text-embedding-004)
instead of 1536 dimensions.

Security:
- Row-level permissions enforced via SurrealDB JWT
- All secrets server-side only
- ATproto OAuth tokens from secure cookies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 00:12:46 +00:00
parent b2c34852d0
commit a1a73d8453
8 changed files with 278 additions and 3 deletions

130
app/api/nodes/route.ts Normal file
View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { AtpAgent, RichText } from '@atproto/api';
import { connectToDB } from '@/lib/db';
import { generateEmbedding } from '@/lib/ai';
import { verifySurrealJwt } from '@/lib/auth/jwt';
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
const atpAccessToken = cookieStore.get('atproto_access_token')?.value;
if (!surrealJwt || !atpAccessToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Verify the JWT and extract user info
const userSession = verifySurrealJwt(surrealJwt);
if (!userSession) {
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}
const { did: userDid } = userSession;
const { title, body, links } = (await request.json()) as {
title: string;
body: string;
links?: string[]; // Array of at-uri strings
};
if (!title || !body) {
return NextResponse.json({ error: 'Title and body are required' }, { status: 400 });
}
const createdAt = new Date().toISOString();
// --- Step 1: Write to Source of Truth (ATproto) ---
let atp_uri: string;
let atp_cid: string;
try {
// Get the PDS URL from environment or use default
const pdsUrl = process.env.BLUESKY_PDS_URL || 'https://bsky.social';
const agent = new AtpAgent({ service: pdsUrl });
// Resume the session with the access token
await agent.resumeSession({
accessJwt: atpAccessToken,
refreshJwt: '', // We don't need refresh for this operation
did: userDid,
handle: userSession.handle,
});
// Format the body as RichText to detect links, mentions, etc.
const rt = new RichText({ text: body });
await rt.detectFacets(agent);
// Create the ATproto record
const response = await agent.api.com.atproto.repo.createRecord({
repo: userDid,
collection: 'com.ponderants.node',
record: {
$type: 'com.ponderants.node',
title,
body: rt.text,
facets: rt.facets,
links: links || [],
createdAt,
},
});
atp_uri = response.uri;
atp_cid = response.cid;
} catch (error) {
console.error('ATproto write error:', error);
return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 });
}
// --- Step 2: Generate AI Embedding (Cache) ---
let embedding: number[];
try {
embedding = await generateEmbedding(title + '\n' + body);
} catch (error) {
console.error('Embedding error:', error);
return NextResponse.json({ error: 'Failed to generate embedding' }, { status: 500 });
}
// --- Step 3: Write to App View Cache (SurrealDB) ---
try {
const db = await connectToDB(surrealJwt);
// Create the node record in our cache.
// The `user_did` field is set, satisfying the 'PERMISSIONS'
// clause defined in the schema.
const newNode = await db.create('node', {
user_did: userDid,
atp_uri: atp_uri,
title: title,
body: body, // Store the raw text body
embedding: embedding,
// coords_3d will be calculated later by UMAP
});
// Handle linking
if (links && links.length > 0) {
// Find the corresponding cache nodes for the AT-URIs
const targetNodesResult = await db.query<[Array<{ id: string }>]>(
'SELECT id FROM node WHERE user_did = $did AND atp_uri IN $links',
{ did: userDid, links: links }
);
const targetNodes = targetNodesResult[0] || [];
// Create graph relations
for (const targetNode of targetNodes) {
await db.query('RELATE $from->links_to->$to', {
from: (newNode as any)[0].id,
to: targetNode.id,
});
}
}
return NextResponse.json(newNode);
} catch (error) {
console.error('SurrealDB write error:', error);
// TODO: Implement rollback for the ATproto post?
// This is a known limitation of the write-through cache pattern.
return NextResponse.json({ error: 'Failed to save to app cache' }, { status: 500 });
}
}