Files
app/docs/steps/step-07.md
2025-11-08 12:44:39 +00:00

6.5 KiB

File: COMMIT_06_WRITE_FLOW.md

Commit 6: Core Write-Through Cache API

Objective

Implement the POST /api/nodes route. This is the core "write-through cache" logic, which is the architectural foundation of the application. It must:

  1. Authenticate the user via their SurrealDB JWT.
  2. Retrieve their ATproto access token (from the encrypted cookie).
  3. Step 1 (Truth): Publish the new node to their PDS using the com.ponderants.node lexicon.
  4. Step 2 (Cache): Generate a gemini-embedding-001 vector from the node's body.
  5. Step 3 (Cache): Write the node, its atp_uri, and its embedding to our SurrealDB cache.

Implementation Specification

1. Create lib/db.ts

Create a helper file at /lib/db.ts for connecting to SurrealDB:

TypeScript

import { Surreal } from 'surrealdb.js';

const db = new Surreal();

/**
* Connects to the SurrealDB instance.
* @param {string} token - The user's app-specific (SurrealDB) JWT.
*/
export async function connectToDB(token: string) {
if (!db.connected) {
await db.connect(process.env.SURREALDB_URL!);
}

// Authenticate as the user for this request.
// This enforces the row-level security (PERMISSIONS)
// defined in the schema for all subsequent queries.
await db.authenticate(token);

return db;
}

2. Create lib/ai.ts

Create a helper file at /lib/ai.ts for AI operations:

TypeScript

import { GoogleGenerativeAI } from '@google/generative-ai';

const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY!);

const embeddingModel = genAI.getGenerativeModel({
model: 'gemini-embedding-001',
});

/**
* Generates a vector embedding for a given text.
* @param text The text to embed.
* @returns A 1536-dimension vector (Array<number>).
*/
export async function generateEmbedding(text: string): Promise<number> {
try {
const result = await embeddingModel.embedContent(text);
return result.embedding.values;
} catch (error) {
console.error('Error generating embedding:', error);
throw new Error('Failed to generate AI embedding.');
}
}

3. Create Write API Route (app/api/nodes/route.ts)

Create the main API file at /app/api/nodes/route.ts:

TypeScript

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';

export async function POST(request: NextRequest) {
const surrealJwt = cookies().get('ponderants-auth')?.value;
const atpAccessToken = cookies().get('atproto_access_token')?.value;

if (!surrealJwt ||!atpAccessToken) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}

let userDid: string;
try {
// Decode the JWT to get the DID for the SurrealDB query
// In a real app, we'd verify it, but for now we just
// pass it to connectToDB which authenticates with it.
const { payload } = jwt.decode(surrealJwt, { complete: true })!;
userDid = (payload as { did: string }).did;
} catch (e) {
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}

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 {
const agent = new AtpAgent({ service: 'https://bsky.social' }); // The service URL may need to be dynamic
await agent.resumeSession({ accessJwt: atpAccessToken, did: userDid, handle: '' }); // Simplified resume

// Format the body as RichText  
const rt \= new RichText({ text: body });  
await rt.detectFacets(agent); // Detect links, mentions

const response \= await agent.post({  
  $type: 'com.ponderants.node',  
  repo: userDid,  
  collection: 'com.ponderants.node',  
  record: {  
    title,  
    body: rt.text,  
    facets: rt.facets, // Include facets for rich text  
    links: links?.map(uri \=\> ({ $link: uri })) ||, // Convert URIs to strong refs  
    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  
});

// Handle linking  
if (links && links.length \> 0) {  
  // Find the corresponding cache nodes for the AT-URIs  
  const targetNodes: { id: string } \= await db.query(  
    'SELECT id FROM node WHERE user\_did \= $did AND atp\_uri IN $links',  
    { did: userDid, links: links }  
  );  
    
  // Create graph relations  
  for (const targetNode of targetNodes) {  
    await db.query('RELATE $from-\>links\_to-\>$to', {  
      from: (newNode as any).id,  
      to: targetNode.id,  
    });  
  }  
}  
  
return NextResponse.json(newNode);

} catch (error) {
console.error('SurrealDB write error:', error);
// TODO: Implement rollback for the ATproto post?
return NextResponse.json({ error: 'Failed to save to app cache' }, { status: 500 });
}
}

Test Specification

This is an API-only commit. It will be tested via the end-to-end flow in Commit 10 (Linking), which will provide the UI (the "Publish" button) to trigger this route.