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:
130
app/api/nodes/route.ts
Normal file
130
app/api/nodes/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user