Files
app/app/api/nodes/route.ts
Albert 49b12a2933 fix: Provide coords_3d field required by schema
Production SurrealDB schema requires coords_3d to be array<number>,
which means it cannot be NONE despite the ASSERT allowing it.
TYPE enforcement happens before ASSERT validation.

This fixes the production error:
"Found NONE for field coords_3d but expected array<number>"

New nodes are initialized at origin [0, 0, 0] for galaxy visualization.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 19:46:29 +00:00

276 lines
9.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { RichText, Agent } from '@atproto/api';
import { RecordId } from 'surrealdb';
import { connectToDB } from '@/lib/db';
import { generateEmbedding } from '@/lib/ai';
import { verifySurrealJwt } from '@/lib/auth/jwt';
import { getOAuthClient } from '@/lib/auth/oauth-client';
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
console.log('[POST /api/nodes] Auth check:', {
hasSurrealJwt: !!surrealJwt,
});
if (!surrealJwt) {
console.error('[POST /api/nodes] Missing auth cookie');
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Verify the JWT and extract user info
const userSession = verifySurrealJwt(surrealJwt);
if (!userSession) {
console.error('[POST /api/nodes] Invalid JWT');
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}
const { did: userDid } = userSession;
console.log('[POST /api/nodes] Verified user DID:', userDid);
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();
// Generate a unique node ID upfront (we'll use this for the detail page link)
const nodeUuid = crypto.randomUUID();
const nodeId = `node:${nodeUuid}`;
const detailUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://ponderants.app'}/galaxy/${encodeURIComponent(nodeId)}`;
// --- Step 1: Write to Source of Truth (ATproto) ---
let atp_uri = '';
let atp_cid = '';
try {
// Get the OAuth client and restore the user's session
const client = await getOAuthClient();
console.log('[POST /api/nodes] Got OAuth client, attempting to restore session for DID:', userDid);
// Restore the session - returns an OAuthSession object directly
const session = await client.restore(userDid);
// Create an Agent from the session
const agent = new Agent(session);
console.log('[POST /api/nodes] Successfully restored OAuth session and created agent');
// Bluesky posts are limited to 300 graphemes
// We'll create a thread for longer content
const fullText = `${title}\n\n${body}`;
// Helper to count graphemes using RichText
function getGraphemeLength(text: string): number {
const rt = new RichText({ text });
return rt.graphemeLength;
}
// Reserve space for the detail page link in first post
const linkSuffix = `\n\nRead more: ${detailUrl}`;
const linkGraphemes = getGraphemeLength(linkSuffix);
// For first post, we need room for the content + link
const firstPostMaxGraphemes = 300 - linkGraphemes - 5; // 5 char buffer
// For subsequent thread posts, we need room for the thread indicator like "(2/3) "
const threadIndicatorMaxGraphemes = 10; // Max graphemes for "(99/99) "
const threadPostMaxGraphemes = 300 - threadIndicatorMaxGraphemes - 5; // 5 char buffer
// Helper function to split text into chunks at word boundaries using grapheme counting
function splitIntoChunks(text: string, firstMaxGraphemes: number, otherMaxGraphemes: number): string[] {
const chunks: string[] = [];
let remainingText = text;
let isFirst = true;
while (remainingText.length > 0) {
const maxGraphemes = isFirst ? firstMaxGraphemes : otherMaxGraphemes;
const graphemeCount = getGraphemeLength(remainingText);
if (graphemeCount <= maxGraphemes) {
chunks.push(remainingText);
break;
}
// Find last space within maxGraphemes
let testText = remainingText;
// Binary search for the right split point
while (getGraphemeLength(testText) > maxGraphemes) {
const lastSpace = testText.lastIndexOf(' ');
if (lastSpace === -1 || lastSpace < testText.length * 0.5) {
// No good space found, just hard cut at character boundary
// Start from the end and work backwards
testText = testText.substring(0, Math.floor(testText.length * 0.9));
} else {
testText = testText.substring(0, lastSpace);
}
}
chunks.push(testText.trim());
remainingText = remainingText.substring(testText.length).trim();
isFirst = false;
}
return chunks;
}
let chunks: string[];
if (getGraphemeLength(fullText) <= firstPostMaxGraphemes) {
// Single post
chunks = [fullText];
} else {
// Split into thread, accounting for link on first post and thread indicators on others
chunks = splitIntoChunks(fullText, firstPostMaxGraphemes, threadPostMaxGraphemes);
}
// Create the thread posts
let previousPost: { uri: string; cid: string } | null = null;
let rootPost: { uri: string; cid: string } | null = null;
const threadUris: string[] = [];
for (let i = 0; i < chunks.length; i++) {
const isFirstPost = i === 0;
let postText = chunks[i];
// Add thread indicator if not first/last post
if (chunks.length > 1 && !isFirstPost) {
postText = `(${i + 1}/${chunks.length}) ${postText}`;
}
// Add detail page link to first post
if (isFirstPost) {
postText += linkSuffix;
}
// Validate grapheme count before posting
const finalGraphemes = getGraphemeLength(postText);
if (finalGraphemes > 300) {
console.error(`[POST /api/nodes] Post ${i + 1} exceeds 300 graphemes (${finalGraphemes})`);
throw new Error(`Post exceeds 300 grapheme limit: ${finalGraphemes} graphemes`);
}
// Format the text as RichText to detect links, mentions, etc.
const rt = new RichText({ text: postText });
await rt.detectFacets(agent);
// Prepare the post record
const postRecord: any = {
$type: 'app.bsky.feed.post',
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
tags: ['ponderants-node'],
};
// If not first post, add reply reference to create a thread
if (previousPost && rootPost) {
postRecord.reply = {
root: { uri: rootPost.uri, cid: rootPost.cid },
parent: { uri: previousPost.uri, cid: previousPost.cid },
};
}
// Create the post
const response = await agent.api.com.atproto.repo.createRecord({
repo: userDid,
collection: 'app.bsky.feed.post',
record: postRecord,
});
threadUris.push(response.data.uri);
previousPost = { uri: response.data.uri, cid: response.data.cid };
// Store the first post's URI and CID as both the root and the main atp_uri
if (isFirstPost) {
rootPost = { uri: response.data.uri, cid: response.data.cid };
atp_uri = response.data.uri;
atp_cid = response.data.cid;
}
}
console.log('[POST /api/nodes] ✓ Published to ATproto PDS as standard post:', atp_uri);
} catch (error) {
console.error('[POST /api/nodes] ATproto write error:', error);
return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 });
}
// --- Step 2: Generate AI Embedding (Cache) ---
// Embeddings are optional - used for vector search and 3D visualization
let embedding: number[] | undefined;
try {
embedding = await generateEmbedding(title + '\n' + body);
console.log('[POST /api/nodes] ✓ Generated embedding vector');
} catch (error) {
console.warn('[POST /api/nodes] ⚠ Embedding generation failed (non-critical):', error);
// Continue without embedding - it's only needed for advanced features
embedding = undefined;
}
// --- Step 3: Write to App View Cache (SurrealDB) ---
// The cache is optional - the ATproto PDS is the source of truth
try {
const db = await connectToDB();
// Create the node record in our cache.
// The `user_did` field is set, satisfying the 'PERMISSIONS'
// clause defined in the schema.
const nodeData: any = {
user_did: userDid,
atp_uri: atp_uri,
title: title,
body: body, // Store the raw text body
coords_3d: [0, 0, 0], // Required by schema - origin coordinates for new nodes
};
// Only include embedding if it was successfully generated
if (embedding) {
nodeData.embedding = embedding;
}
// Use RecordId object to specify table and ID separately
// SurrealDB client expects RecordId object, not 'table:id' string
const newNode = await db.create(new RecordId('node', nodeUuid), nodeData);
// 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,
});
}
}
console.log('[POST /api/nodes] ✓ Cached node in SurrealDB');
return NextResponse.json({ success: true, atp_uri, node: newNode });
} catch (error) {
console.warn('[POST /api/nodes] ⚠ SurrealDB cache write failed (non-critical):', error);
// The node was successfully published to ATproto (source of truth)
// Cache failure is non-critical - advanced features may be unavailable
return NextResponse.json({
success: true,
atp_uri,
warning: 'Node published to Bluesky, but cache update failed. Advanced features may be unavailable.',
});
}
}