05 - Script em nodejs para fazer sync

Dependências

No mesmo projeto do seu Next.js (ou em outro diretório Node):

npm install @supabase/supabase-js npm install -D typescript ts-node @types/node

Se ainda não tiver, inicialize o TS:

npx tsc --init

Variáveis de ambiente

No .env.local ou .env que você vai usar pro script:

Vide o link abaixo para entender das novas chaves do Supabase:
Supabase chave sb_secret que ignora RLS

NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...       # service role
SUPABASE_BUCKET_DOCS=docs
OBSIDIAN_DOCS_PATH=C:\Users\voce\Obsidian\MeuVault\portal-docs

No Linux/WSL, seria algo como /home/voce/Obsidian/MeuVault/portal-docs.

Cliente admin do Supabase (reuso)

lib/supabaseAdmin.ts (se já existir, reaproveite):

// lib/supabaseAdmin.ts
import { createClient } from '@supabase/supabase-js';

const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

export const supabaseAdmin = createClient(url, serviceKey, {
  auth: {
    autoRefreshToken: false,
    persistSession: false,
  },
});

Script scripts/sync-obsidian.ts

Crie scripts/sync-obsidian.ts:

// scripts/sync-obsidian.ts
import 'dotenv/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import { supabaseAdmin } from '../lib/supabaseAdmin';

const ROOT = process.env.OBSIDIAN_DOCS_PATH!;
const BUCKET = process.env.SUPABASE_BUCKET_DOCS || 'docs';

async function walk(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  const files: string[] = [];

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...(await walk(fullPath)));
    } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
      files.push(fullPath);
    }
  }

  return files;
}

function getSlugAndStoragePath(fullPath: string): { slug: string; storagePath: string } {
  const rel = path.relative(ROOT, fullPath).replace(/\\/g, '/'); // normaliza para /
  const storagePath = rel;                                       // ex: financeiro/relatorio-mensal.md
  const slug = rel.replace(/\.md$/i, '');                         // ex: financeiro/relatorio-mensal
  return { slug, storagePath };
}

async function extractTitle(markdown: string, fallback: string): Promise<string> {
  // Procura a primeira linha que comece com "# "
  const lines = markdown.split('\n');
  for (const line of lines) {
    const trimmed = line.trim();
    if (trimmed.startsWith('# ')) {
      return trimmed.replace(/^#\s+/, '').trim();
    }
  }

  // senão, usa o fallback (nome do arquivo sem extensão)
  return fallback;
}

async function syncFile(fullPath: string) {
  const { slug, storagePath } = getSlugAndStoragePath(fullPath);

  const fileBuffer = await fs.readFile(fullPath);
  const markdown = fileBuffer.toString('utf8');

  const fileName = path.basename(storagePath, '.md');
  const title = await extractTitle(markdown, fileName);

  console.log(`➡️  Sync: ${slug} (${storagePath})`);

  // 1) Upsert em app_docs
  const { data: doc, error: docError } = await supabaseAdmin
    .from('app_docs')
    .upsert(
      {
        slug,
        title,
        bucket: BUCKET,
        storage_path: storagePath,
      },
      { onConflict: 'slug' }
    )
    .select()
    .single();

  if (docError) {
    console.error(`Erro upsert app_docs (${slug}):`, docError.message);
    return;
  }

  // 2) Upload/Upsert no Storage
  const { error: storageError } = await supabaseAdmin.storage
    .from(BUCKET)
    .upload(storagePath, fileBuffer, {
      upsert: true,
      contentType: 'text/markdown; charset=utf-8',
    });

  if (storageError) {
    console.error(`Erro upload Storage (${storagePath}):`, storageError.message);
    return;
  }

  console.log(`✅ OK: ${slug}`);
}

async function main() {
  console.log('🔍 Varre', ROOT);
  const files = await walk(ROOT);
  console.log(`Encontrados ${files.length} arquivos .md`);

  for (const file of files) {
    await syncFile(file);
  }

  console.log('✨ Sync concluído');
}

main().catch((err) => {
  console.error('Erro fatal no sync:', err);
  process.exit(1);
});

Script no package.json

{
  "scripts": {
    "sync:docs": "ts-node scripts/sync-obsidian.ts"
  }
}

Rodar

pnpm run sync:docs

Isso vai:

⚠️ Esse script não deleta docs do banco/storage se você apagar o arquivo local.
Se quiser remover também, dá pra estender: primeiro listar app_docs e comparar com os arquivos encontrados.


Acoplamento com o Obsidian

Temos 3 opções:

Opção 1 : Rodar manualmente

Sempre que fizermos alterações importantes nos docs:

npm run sync:docs

Simples e explícito.

Opção 2 : Integrar com Git + CI

Se sua Vault (ou a pasta portal-docs/) estiver versionada no GitHub:

  1. Quando fizer push pra main,
  2. Um GitHub Actions roda npm run sync:docs em um runner,
  3. O Actions usa SUPABASE_SERVICE_ROLE_KEY como secret,
  4. E sincroniza com o Supabase.

Fluxo:

Opção 3 : Plugin de "Run shell command" no Obsidian

Existem plugins comunitários que permitem rodar comandos externos (ex: “Shell commands”, “Obsidian Git”, etc.).

Você poderia:

pnpm run sync:docs

Reforço de Segurança

Já que essa chave aparece no painel:

GitHub → Repo → Settings → Secrets → SUPABASE_SERVICE_ROLE_KEY

export default {
  serverRuntimeConfig: {
    SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
  },
  publicRuntimeConfig: {}, // evita exposição
};

Mas como estamos usando server actions e o valor nunca vai para client, já está ok.

Exemplo completo

import type { NextConfig } from "next";
const nextConfig: NextConfig = {
  /* config options here */
};

export default {
  ...nextConfig,
  env: {
    SUPABASE_SERVICE_ROLE_KEY: undefined, // impede vazamento no browser
  },
  serverRuntimeConfig: {
    SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
  },
  publicRuntimeConfig: {}
}