mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 09:52:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b9e9333f |
@@ -21,17 +21,14 @@ GBrain is tuned for the Supabase **Transaction pooler** (port 6543): it
|
||||
auto-disables prepared statements there and routes `engine.transaction()`
|
||||
(migrations, DDL, sync imports) to a derived **direct** connection
|
||||
(`db.<ref>.supabase.co:5432`). That direct host is IPv6-only, so on an
|
||||
IPv4-only host it is unreachable. When that happens gbrain now falls back to
|
||||
the pooler automatically (one stderr warning, then single-pool mode for the
|
||||
rest of the process) — but the pooler's ~2-min statement timeout can truncate
|
||||
very long migrations or bulk imports.
|
||||
IPv4-only host, reads work but sync **silently skips most pages**. This is the
|
||||
number one cause of "sync ran but nothing happened."
|
||||
|
||||
Fix: make the direct connection reachable over IPv4. Either set
|
||||
`GBRAIN_DIRECT_DATABASE_URL` to the **Session pooler** string (port 5432 on the
|
||||
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on.
|
||||
`GBRAIN_DISABLE_DIRECT_POOL=1` skips the direct pool (and the fallback warning)
|
||||
entirely. Verify by running `gbrain sync` and checking that the page count in
|
||||
`gbrain stats` matches the syncable file count in the repo.
|
||||
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on. Verify by
|
||||
running `gbrain sync` and checking that the page count in `gbrain stats` matches
|
||||
the syncable file count in the repo.
|
||||
|
||||
### The Primitives
|
||||
|
||||
|
||||
+5
-8
@@ -2720,17 +2720,14 @@ GBrain is tuned for the Supabase **Transaction pooler** (port 6543): it
|
||||
auto-disables prepared statements there and routes `engine.transaction()`
|
||||
(migrations, DDL, sync imports) to a derived **direct** connection
|
||||
(`db.<ref>.supabase.co:5432`). That direct host is IPv6-only, so on an
|
||||
IPv4-only host it is unreachable. When that happens gbrain now falls back to
|
||||
the pooler automatically (one stderr warning, then single-pool mode for the
|
||||
rest of the process) — but the pooler's ~2-min statement timeout can truncate
|
||||
very long migrations or bulk imports.
|
||||
IPv4-only host, reads work but sync **silently skips most pages**. This is the
|
||||
number one cause of "sync ran but nothing happened."
|
||||
|
||||
Fix: make the direct connection reachable over IPv4. Either set
|
||||
`GBRAIN_DIRECT_DATABASE_URL` to the **Session pooler** string (port 5432 on the
|
||||
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on.
|
||||
`GBRAIN_DISABLE_DIRECT_POOL=1` skips the direct pool (and the fallback warning)
|
||||
entirely. Verify by running `gbrain sync` and checking that the page count in
|
||||
`gbrain stats` matches the syncable file count in the repo.
|
||||
`pooler.supabase.com` host, IPv4), or enable Supabase's IPv4 add-on. Verify by
|
||||
running `gbrain sync` and checking that the page count in `gbrain stats` matches
|
||||
the syncable file count in the repo.
|
||||
|
||||
### The Primitives
|
||||
|
||||
|
||||
+15
-5
@@ -43,7 +43,7 @@ import {
|
||||
} from '../core/link-extraction.ts';
|
||||
import { createProgress } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
import { pathToSlug, pruneDir, isSyncable } from '../core/sync.ts';
|
||||
import { pathToSlug, slugifyPath, pruneDir, isSyncable } from '../core/sync.ts';
|
||||
// v0.41.18.0: withRetry + isRetryableConnError + WithRetryOpts moved to
|
||||
// src/core/retry.ts as the canonical primitive. Engine methods
|
||||
// (addLinksBatch/addTimelineEntriesBatch/upsertChunks) now self-retry via
|
||||
@@ -269,14 +269,24 @@ export function extractMarkdownLinks(content: string): { name: string; relTarget
|
||||
export function resolveSlug(fileDir: string, relTarget: string, allSlugs: Set<string>): string | null {
|
||||
const targetNoExt = relTarget.endsWith('.md') ? relTarget.slice(0, -3) : relTarget;
|
||||
|
||||
const s1 = join(fileDir, targetNoExt);
|
||||
if (allSlugs.has(s1)) return s1;
|
||||
// Issue #1964: wikilinks carry raw Obsidian paths (`[[llm-wiki/entities/AI 3.0]]`)
|
||||
// but allSlugs holds sync-slugified slugs (`llm-wiki/entities/ai-3.0`). Try the
|
||||
// raw candidate first (back-compat), then the sync-consistent slugified form.
|
||||
const hit = (candidate: string): string | null => {
|
||||
if (allSlugs.has(candidate)) return candidate;
|
||||
const slugified = slugifyPath(candidate);
|
||||
if (slugified !== candidate && allSlugs.has(slugified)) return slugified;
|
||||
return null;
|
||||
};
|
||||
|
||||
const s1 = hit(join(fileDir, targetNoExt));
|
||||
if (s1) return s1;
|
||||
|
||||
const parts = fileDir.split('/').filter(Boolean);
|
||||
for (let strip = 1; strip <= parts.length; strip++) {
|
||||
const ancestor = parts.slice(0, parts.length - strip).join('/');
|
||||
const candidate = ancestor ? join(ancestor, targetNoExt) : targetNoExt;
|
||||
if (allSlugs.has(candidate)) return candidate;
|
||||
const candidate = hit(ancestor ? join(ancestor, targetNoExt) : targetNoExt);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1078,9 +1078,6 @@ async function initPostgres(opts: {
|
||||
console.warn(' Direct connections are IPv6 only and fail in many environments.');
|
||||
console.warn(' Use the Transaction pooler connection string instead (port 6543):');
|
||||
console.warn(' Supabase Dashboard > Connect (top bar) > Connection String > Transaction pooler');
|
||||
console.warn(' (With a pooler URL, gbrain derives a direct connection for DDL and falls back');
|
||||
console.warn(' to the pooler automatically if that host is unreachable. Power users:');
|
||||
console.warn(' GBRAIN_DIRECT_DATABASE_URL overrides the derived URL; GBRAIN_DISABLE_DIRECT_POOL=1 disables it.)');
|
||||
console.warn('');
|
||||
}
|
||||
|
||||
@@ -1094,9 +1091,6 @@ async function initPostgres(opts: {
|
||||
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
|
||||
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
|
||||
console.error('Use the Transaction pooler connection string instead (port 6543).');
|
||||
console.error('(gbrain derives its own direct connection from pooler URLs for DDL; if that host is');
|
||||
console.error('unreachable it falls back to the pooler. GBRAIN_DIRECT_DATABASE_URL overrides the');
|
||||
console.error('derived URL; GBRAIN_DISABLE_DIRECT_POOL=1 disables the direct pool entirely.)');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -167,25 +167,6 @@ export function deriveDirectUrl(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes that mean "the direct host is unreachable from this network"
|
||||
* (#1641). The auto-derived db.<ref>.supabase.co host is IPv6-only without
|
||||
* the paid IPv4 add-on, so ENOTFOUND/ECONNREFUSED here is expected on
|
||||
* IPv4-only networks — we fall back to the pooler instead of failing init.
|
||||
*/
|
||||
const NETWORK_UNREACHABLE_CODES = [
|
||||
'ENOTFOUND', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH',
|
||||
'ETIMEDOUT', 'CONNECT_TIMEOUT',
|
||||
];
|
||||
|
||||
/** True when err looks like a network-unreachable failure (not auth/SQL). */
|
||||
export function isNetworkUnreachableError(err: unknown): boolean {
|
||||
const code = (err as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string' && NETWORK_UNREACHABLE_CODES.includes(code)) return true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NETWORK_UNREACHABLE_CODES.some(c => msg.includes(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read kill-switch state from env. Subordinate to parent manager's state
|
||||
* when present (A2 inheritance).
|
||||
@@ -338,30 +319,7 @@ export class ConnectionManager {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
let pool: Sql | null;
|
||||
try {
|
||||
pool = await this._directInit;
|
||||
} catch (err) {
|
||||
// #1641: the derived direct host (db.<ref>.supabase.co) is IPv6-only
|
||||
// without Supabase's IPv4 add-on. On IPv4-only networks the direct
|
||||
// pool can never connect — permanently fall back to the read pool
|
||||
// (self-activating kill-switch) instead of failing init/migrations.
|
||||
// Non-network errors (auth, SQL) still throw: they mean misconfig,
|
||||
// not unreachability.
|
||||
if (isNetworkUnreachableError(err)) {
|
||||
const alreadyWarned = this._killSwitch;
|
||||
this._killSwitch = true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!alreadyWarned) console.error(
|
||||
`gbrain: direct connection to ${this._directUrl ? this.hostOnly(this._directUrl) : 'unknown host'} unreachable (${msg}); ` +
|
||||
'falling back to the pooler for DDL/bulk (long migrations may hit the pooler statement timeout). ' +
|
||||
'Set GBRAIN_DIRECT_DATABASE_URL to a reachable direct URL (e.g. the Session pooler, port 5432) or enable the Supabase IPv4 add-on; ' +
|
||||
'GBRAIN_DISABLE_DIRECT_POOL=1 silences this.',
|
||||
);
|
||||
return this.getReadPool();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const pool = await this._directInit;
|
||||
if (!pool) {
|
||||
// Defensive — initDirectPool should have thrown.
|
||||
throw new Error('connection-manager: direct pool init returned null');
|
||||
@@ -392,9 +350,8 @@ export class ConnectionManager {
|
||||
},
|
||||
};
|
||||
const t0 = Date.now();
|
||||
let pool: Sql | null = null;
|
||||
try {
|
||||
pool = postgres(this._directUrl, opts);
|
||||
const pool = postgres(this._directUrl, opts);
|
||||
// Probe to validate connectivity early.
|
||||
await pool`SELECT 1`;
|
||||
logConnectionEvent({
|
||||
@@ -405,9 +362,6 @@ export class ConnectionManager {
|
||||
});
|
||||
return pool;
|
||||
} catch (err) {
|
||||
// Don't leak the failed pool's sockets/timers (#1641 fallback keeps
|
||||
// the process running afterward).
|
||||
if (pool) await endPoolBounded(pool);
|
||||
logConnectionEvent({
|
||||
pool: 'ddl',
|
||||
op: 'error',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { PageType } from './types.ts';
|
||||
import { ensureWellFormed } from './text-safe.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
/**
|
||||
* v0.42.7 — link-extraction version stamp. Bump this ISO timestamp whenever the
|
||||
@@ -482,14 +483,29 @@ export async function extractPageLinks(
|
||||
// pre-v0.40.8.2 behavior of dropping bare wikilinks outside
|
||||
// DIR_PATTERN.
|
||||
if (ref.needsResolution) {
|
||||
if (!opts.globalBasename || typeof resolver.resolveBasenameMatches !== 'function') {
|
||||
continue;
|
||||
}
|
||||
if (typeof resolver.resolveBasenameMatches !== 'function') continue;
|
||||
// Issue #972 (codex): resolve by the wikilink TARGET (ref.slug — the
|
||||
// text inside `[[...]]` before any `|`), NOT the display alias
|
||||
// (ref.name = match[2]). `[[struktura|the project]]` must resolve
|
||||
// `struktura`, not "the project". The display text is for context only.
|
||||
const matches = await resolver.resolveBasenameMatches(ref.slug);
|
||||
//
|
||||
// Issue #1964: a dir-qualified wikilink (`[[llm-wiki/entities/AI 3.0]]`)
|
||||
// carries a raw Obsidian path while page slugs are sync-slugified
|
||||
// (`llm-wiki/entities/ai-3.0`). Slugify the path the same way sync does,
|
||||
// then match by exact slug or path-suffix (wiki-root-relative authoring).
|
||||
// This runs regardless of global_basename — it's dir-qualified, so the
|
||||
// cross-dir false-positive risk the flag guards against doesn't apply.
|
||||
// Mirrors the FS path's resolveSlug ancestor walk. Bare `[[name]]`
|
||||
// wikilinks still require the global_basename flag.
|
||||
let matches: string[] = [];
|
||||
const slugified = ref.slug.includes('/') ? slugifyPath(ref.slug) : '';
|
||||
if (slugified.includes('/')) {
|
||||
const tail = slugified.slice(slugified.lastIndexOf('/') + 1);
|
||||
matches = (await resolver.resolveBasenameMatches(tail))
|
||||
.filter(m => m === slugified || m.endsWith(`/${slugified}`));
|
||||
} else if (opts.globalBasename) {
|
||||
matches = await resolver.resolveBasenameMatches(ref.slug);
|
||||
}
|
||||
if (matches.length === 0) continue;
|
||||
const idx = content.indexOf(ref.slug);
|
||||
const context = idx >= 0 ? excerpt(content, idx, 240) : ref.name;
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
isSupabasePoolerUrl,
|
||||
deriveDirectUrl,
|
||||
readKillSwitchEnv,
|
||||
isNetworkUnreachableError,
|
||||
resolveDirectPoolSize,
|
||||
ConnectionManager,
|
||||
DEFAULT_DIRECT_POOL_SIZE,
|
||||
@@ -239,65 +238,3 @@ describe('ConnectionManager — parent inheritance (A2)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNetworkUnreachableError (#1641)', () => {
|
||||
test('classifies network codes as unreachable', () => {
|
||||
for (const code of ['ENOTFOUND', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH', 'ETIMEDOUT', 'CONNECT_TIMEOUT']) {
|
||||
const err = Object.assign(new Error('connect failed'), { code });
|
||||
expect(isNetworkUnreachableError(err)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies by message when code absent', () => {
|
||||
expect(isNetworkUnreachableError(new Error('getaddrinfo ENOTFOUND db.abc.supabase.co'))).toBe(true);
|
||||
});
|
||||
|
||||
test('auth/SQL errors are NOT unreachable', () => {
|
||||
expect(isNetworkUnreachableError(new Error('password authentication failed for user "postgres"'))).toBe(false);
|
||||
expect(isNetworkUnreachableError(new Error('syntax error at or near "SELEC"'))).toBe(false);
|
||||
expect(isNetworkUnreachableError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionManager — direct-pool fallback on unreachable host (#1641)', () => {
|
||||
let originalKillSwitch: string | undefined;
|
||||
let originalError: typeof console.error;
|
||||
let errLines: string[];
|
||||
beforeEach(() => {
|
||||
originalKillSwitch = process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
||||
delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
||||
originalError = console.error;
|
||||
errLines = [];
|
||||
console.error = (...args: unknown[]) => { errLines.push(args.join(' ')); };
|
||||
});
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
if (originalKillSwitch === undefined) delete process.env.GBRAIN_DISABLE_DIRECT_POOL;
|
||||
else process.env.GBRAIN_DISABLE_DIRECT_POOL = originalKillSwitch;
|
||||
});
|
||||
|
||||
test('ddl() falls back to the read pool when the direct host is unreachable', async () => {
|
||||
const cm = new ConnectionManager({
|
||||
url: 'postgresql://postgres.abc:p@aws.pooler.supabase.com:6543/db',
|
||||
// 127.0.0.1:9 (discard) → instant ECONNREFUSED, the IPv4-only-network shape.
|
||||
directUrl: 'postgresql://postgres:p@127.0.0.1:9/db',
|
||||
});
|
||||
const fakeReadPool = {} as ReturnType<typeof ConnectionManager.prototype.read>;
|
||||
cm.setReadPool(fakeReadPool);
|
||||
expect(cm.isDualPoolActive()).toBe(true);
|
||||
|
||||
const pool = await cm.ddl(); // without the fix this throws ECONNREFUSED
|
||||
expect(pool).toBe(fakeReadPool);
|
||||
// Self-activating kill-switch: subsequent calls skip the direct pool.
|
||||
expect(cm.isKillSwitchActive()).toBe(true);
|
||||
expect(cm.isDualPoolActive()).toBe(false);
|
||||
expect(cm.describeMode().mode).toBe('single (kill-switch)');
|
||||
// One stderr line mentioning the power-user override.
|
||||
const warning = errLines.filter(l => l.includes('GBRAIN_DIRECT_DATABASE_URL'));
|
||||
expect(warning.length).toBe(1);
|
||||
|
||||
const again = await cm.ddl();
|
||||
expect(again).toBe(fakeReadPool);
|
||||
expect(errLines.filter(l => l.includes('GBRAIN_DIRECT_DATABASE_URL')).length).toBe(1);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
@@ -389,6 +389,33 @@ describe('resolveSlugAll', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── issue #1964: cross-directory wikilinks — slug/path mismatch ──────────
|
||||
|
||||
describe('issue #1964: raw Obsidian wikilink paths resolve to sync-slugified slugs', () => {
|
||||
test('resolveSlug slugifies the candidate (sync-consistent), no flag needed', () => {
|
||||
const all = new Set(['llm-wiki/entities/ai-3.0']);
|
||||
// Wikilink literal `[[llm-wiki/entities/AI 3.0]]` — spaces + uppercase.
|
||||
expect(resolveSlug('llm-wiki/notes', 'llm-wiki/entities/AI 3.0.md', all))
|
||||
.toBe('llm-wiki/entities/ai-3.0');
|
||||
});
|
||||
|
||||
test('resolveSlug slugifies raw (unslugified) fileDir too', () => {
|
||||
const all = new Set(['llm-wiki/entities/ai-3.0']);
|
||||
// fileDir comes from dirname(relPath) — the raw on-disk directory.
|
||||
expect(resolveSlug('LLM Wiki/Notes', 'entities/AI 3.0.md', all))
|
||||
.toBe('llm-wiki/entities/ai-3.0');
|
||||
});
|
||||
|
||||
test('extractLinksFromFile resolves cross-directory wikilink with flag OFF as a typed edge', async () => {
|
||||
const allSlugs = new Set(['llm-wiki/entities/ai-3.0', 'llm-wiki/notes/roadmap']);
|
||||
const content = '---\ntitle: Roadmap\ntype: concept\n---\n\nSee [[llm-wiki/entities/AI 3.0]].\n';
|
||||
const links = await extractLinksFromFile(content, 'llm-wiki/notes/roadmap.md', allSlugs);
|
||||
expect(links.map(l => l.to_slug)).toEqual(['llm-wiki/entities/ai-3.0']);
|
||||
// Dir-qualified path resolution is exact, NOT the basename fallback.
|
||||
expect(links[0].link_type).not.toBe('wikilink_basename');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #972 repro: bare wikilinks resolve when flag is on', () => {
|
||||
// End-to-end: reproduces the issue's exact repro inside a tempdir +
|
||||
// PGLite, then asserts edge count under both flag states.
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
parseTimelineEntries,
|
||||
isAutoLinkEnabled,
|
||||
FRONTMATTER_LINK_MAP,
|
||||
buildBasenameIndex,
|
||||
queryBasenameIndex,
|
||||
type SlugResolver,
|
||||
} from '../src/core/link-extraction.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
@@ -424,6 +426,46 @@ describe('extractPageLinks', () => {
|
||||
expect(strk!.linkType).toBe('wikilink_basename');
|
||||
});
|
||||
|
||||
// ─── issue #1964: dir-qualified wikilinks with raw Obsidian paths ────────
|
||||
|
||||
test('#1964: dir-qualified wikilink resolves via sync-consistent slugification (flag OFF)', async () => {
|
||||
// `[[llm-wiki/entities/AI 3.0]]` is a raw Obsidian path; the page slug
|
||||
// is the sync-slugified `llm-wiki/entities/ai-3.0`. Must resolve WITHOUT
|
||||
// global_basename (it's dir-qualified) and must NOT leak to a same-tail
|
||||
// page in a different directory.
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) =>
|
||||
name === 'ai-3.0' ? ['other/ai-3.0', 'llm-wiki/entities/ai-3.0'] : [],
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'llm-wiki/notes/roadmap',
|
||||
'See [[llm-wiki/entities/AI 3.0]] for the model.',
|
||||
{}, 'concept', resolver,
|
||||
// opts.globalBasename omitted (= false) — path is dir-qualified
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['llm-wiki/entities/ai-3.0']);
|
||||
expect(candidates[0].linkType).toBe('wikilink_basename');
|
||||
expect(candidates[0].linkSource).toBe('wikilink-resolved');
|
||||
});
|
||||
|
||||
test('#1964: path-suffix match resolves wiki-root-relative paths against a real index', async () => {
|
||||
// Author writes `[[llm-wiki/entities/AI 3.0]]` but the brain nests the
|
||||
// wiki under a vault dir. Suffix match rescues it; queried through the
|
||||
// REAL basename index so the tail-key lookup is exercised end to end.
|
||||
const idx = buildBasenameIndex(['vault/llm-wiki/entities/ai-3.0', 'people/ai-3.0']);
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) => queryBasenameIndex(idx, name),
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'vault/llm-wiki/notes/roadmap',
|
||||
'See [[llm-wiki/entities/AI 3.0]].',
|
||||
{}, 'concept', resolver,
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['vault/llm-wiki/entities/ai-3.0']);
|
||||
});
|
||||
|
||||
test('opts.skipFrontmatter suppresses the frontmatter pass', async () => {
|
||||
// Real resolver shape that WOULD resolve frontmatter source: too,
|
||||
// but skipFrontmatter blocks the path entirely.
|
||||
|
||||
Reference in New Issue
Block a user