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>
276 lines
9.8 KiB
TypeScript
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.',
|
|
});
|
|
}
|
|
}
|