mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(conversation-parser): add bold-name-no-time builtin (Circleback/Granola/Zoom, no timestamp) The 14th built-in pattern parses `**Speaker:** text` transcripts with NO per-line timestamp — the shape Circleback / Granola / Zoom emit. Every prior builtin required a time anchor, so this shape matched nothing: a production brain had 104 conversation pages + 3,423 eligible pages silently extracting zero facts. Messages anchor at T00:00:00Z of the frontmatter date (no fabricated wall-clock; line order preserves sequence), same convention as irc-classic. Hardening beyond the original community proposal: - regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`: the colon-inside-bold (NOT declaration order) is what prevents shadowing bold-paren-time; the `(?!\[)` lookahead rejects telegram-bracket `**[18:37] Name:**` so disabling telegram-bracket yields an honest no_match instead of speaker="[18:37] Name". - new optional PatternEntry.score_full_body: `**Label:** text` is a common prose idiom, so a notes page with bold labels clustered in its first 10 lines scored 0.3 on the head pass (NOT < SCORING_HEAD_TRIGGER_THRESHOLD, so the full-body fallback never fired) and cleared the 0.05 floor. parse.ts now recomputes the winner's score over the full body before the floor, so such a page drops to its true low density and stays no_match. - scrubbed pre-existing real names from bold-paren-time test_positive samples (privacy rule). Fixtures use placeholder names only. Pinned by new bold-name-no-time + clustered-head no_match cases in parse.test.ts and the eval corpus. Co-Authored-By: garrytan-agents <noreply@github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(orphans): scope orphan_ratio + find_orphans by source; fix total_linkable denominator `gbrain doctor --source <id>` and `gbrain orphans --source <id>` now scope the orphan scan to that source instead of reporting brain-wide. Three fixes: - findOrphanPages(opts?: { sourceId?, sourceIds? }) on both engines scopes the CANDIDATE set (scalar `= $1` or federated `= ANY($1::text[])`). Inbound links from ANY source still count, so a page in source X linked FROM source Y is reachable and NOT an orphan of X (the deliberate, less-surprising definition). - corrected the total_linkable denominator in findOrphans: it now enumerates all live pages (scoped) and subtracts every excluded-by-slug page, not just excluded orphans. The old `total - excludedOrphans` left excluded NON-orphan pages (templates/, scratch/) with inbound links in the denominator, inflating it and suppressing warnings. Changes orphan_ratio output for every brain, in the accurate direction. - the find_orphans MCP op threads sourceScopeOpts(ctx), closing a cross-source read leak where a source-bound OAuth client saw brain-wide orphans (v0.34.1 source-isolation class). doctor uses an explicit `--source` flag parse (NOT resolveSourceWithTier, which would scope bare invocations to a default), and under explicit --source reports the ratio with a low-scale caveat below 100 entity pages instead of a vacuous "ok". Thin-client doctor --source orphan_ratio deferred (TODOS.md). Pinned by test/orphans-source-scope.test.ts (PGLite: scoping, cross-source inbound, denominator, find_orphans op scope) + a Postgres↔PGLite parity case in test/e2e/engine-parity.test.ts (scalar + federated binding). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: v0.41.29.0 — bold-name-no-time + orphan source scoping VERSION + package.json → 0.41.29.0; CHANGELOG entry; CLAUDE.md conversation-parser (13→14 patterns) + orphans source-scoping notes; regenerated llms bundles; TODOS for thin-client doctor --source + check-test-real-names widening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <noreply@github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
416 lines
17 KiB
TypeScript
416 lines
17 KiB
TypeScript
/**
|
|
* Engine Parity E2E
|
|
*
|
|
* Codex flagged that searchKeyword behavior differs structurally between
|
|
* the two engines (Postgres uses a CTE that ranks pages then picks best
|
|
* chunk; PGLite returns chunks directly). Without verification, source-aware
|
|
* ranking could pass on PGLite and silently fail on Postgres.
|
|
*
|
|
* Strategy: seed identical corpora into both engines, run identical queries,
|
|
* assert top-5 slug ordering matches.
|
|
*
|
|
* Gated by DATABASE_URL — skips gracefully if no real Postgres. Always runs
|
|
* the PGLite half so the seed/query path is at least exercised.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
|
|
import type { BrainEngine } from '../../src/core/engine.ts';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
|
|
|
const SKIP_PG = !hasDatabase();
|
|
const describeBoth = SKIP_PG ? describe.skip : describe;
|
|
|
|
function basisEmbedding(idx: number, dim = 1536): Float32Array {
|
|
const emb = new Float32Array(dim);
|
|
emb[idx % dim] = 1.0;
|
|
return emb;
|
|
}
|
|
|
|
interface SeedPage {
|
|
slug: string;
|
|
type: 'writing' | 'concept' | 'note' | 'person' | 'company';
|
|
title: string;
|
|
body: string;
|
|
embeddingDim: number;
|
|
}
|
|
|
|
const SEED_PAGES: SeedPage[] = [
|
|
{
|
|
slug: 'originals/talks/article-outline-fat-code',
|
|
type: 'writing',
|
|
title: 'Fat Code Thin Harness — Part 3',
|
|
body: 'fat code thin harness pattern part 3 production case studies',
|
|
embeddingDim: 7,
|
|
},
|
|
{
|
|
slug: 'concepts/fat-code-thin-harness',
|
|
type: 'concept',
|
|
title: 'Fat Code Thin Harness',
|
|
body: 'reusable concept fat code thin harness architecture',
|
|
embeddingDim: 14,
|
|
},
|
|
{
|
|
slug: 'openclaw/chat/2026-04-15',
|
|
type: 'note',
|
|
title: '2026-04-15 chat',
|
|
body:
|
|
'fat code thin harness fat code thin harness discussion went on at length, ' +
|
|
'fat code thin harness came up again and again, fat code thin harness fat code thin harness.',
|
|
embeddingDim: 8,
|
|
},
|
|
{
|
|
slug: 'openclaw/chat/2026-04-16',
|
|
type: 'note',
|
|
title: '2026-04-16 chat',
|
|
body:
|
|
'fat code thin harness once more, fat code thin harness fat code thin harness, ' +
|
|
'still talking about fat code thin harness fat code thin harness.',
|
|
embeddingDim: 9,
|
|
},
|
|
{
|
|
slug: 'people/example-founder',
|
|
type: 'person',
|
|
title: 'Example Founder',
|
|
body: 'example founder unrelated content for distraction',
|
|
embeddingDim: 50,
|
|
},
|
|
];
|
|
|
|
async function seedEngine(eng: BrainEngine) {
|
|
for (const p of SEED_PAGES) {
|
|
await eng.putPage(p.slug, {
|
|
type: p.type,
|
|
title: p.title,
|
|
compiled_truth: p.body,
|
|
timeline: '',
|
|
});
|
|
const chunks: ChunkInput[] = [
|
|
{
|
|
chunk_index: 0,
|
|
chunk_text: p.body,
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(p.embeddingDim),
|
|
token_count: p.body.split(/\s+/).length,
|
|
},
|
|
];
|
|
await eng.upsertChunks(p.slug, chunks);
|
|
}
|
|
}
|
|
|
|
const QUERIES = [
|
|
'fat code thin harness',
|
|
'fat code thin harness part 3',
|
|
'fat code production',
|
|
];
|
|
|
|
describeBoth('Engine parity — Postgres vs PGLite', () => {
|
|
let pgEngine: BrainEngine;
|
|
let pgliteEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
pgEngine = await setupDB();
|
|
await seedEngine(pgEngine);
|
|
|
|
pgliteEngine = new PGLiteEngine();
|
|
await pgliteEngine.connect({});
|
|
await pgliteEngine.initSchema();
|
|
await seedEngine(pgliteEngine);
|
|
}, 90_000);
|
|
|
|
afterAll(async () => {
|
|
await pgliteEngine.disconnect();
|
|
await teardownDB();
|
|
}, 30_000);
|
|
|
|
for (const q of QUERIES) {
|
|
test(`searchKeyword: top-5 slugs match for "${q}"`, async () => {
|
|
const pgResults = await pgEngine.searchKeyword(q, { limit: 5 });
|
|
const pgliteResults = await pgliteEngine.searchKeyword(q, { limit: 5 });
|
|
|
|
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
|
|
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
|
|
|
|
// Top result MUST match (the swamp-resistance guarantee).
|
|
expect(pgSlugs[0]).toBe(pgliteSlugs[0]);
|
|
// Sets should match (allowing some ordering drift on lower-ranked
|
|
// results since FTS rank function differences between engines are
|
|
// out of scope for this fix).
|
|
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
|
|
});
|
|
}
|
|
|
|
test('searchVector: top result matches between engines', async () => {
|
|
const queryVec = basisEmbedding(7); // article direction
|
|
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
|
|
const pgliteResults = await pgliteEngine.searchVector(queryVec, { limit: 5 });
|
|
|
|
expect(pgResults[0]?.slug).toBe(pgliteResults[0]?.slug);
|
|
});
|
|
|
|
test('hard-exclude is consistent across engines', async () => {
|
|
// Both engines should hide test/ pages by default; both should opt
|
|
// them back in via include_slug_prefixes.
|
|
await pgEngine.putPage('test/parity-fixture', {
|
|
type: 'note',
|
|
title: 'parity test fixture',
|
|
compiled_truth: 'parity test fixture content',
|
|
timeline: '',
|
|
});
|
|
await pgEngine.upsertChunks('test/parity-fixture', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'parity test fixture content',
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(20),
|
|
token_count: 5,
|
|
}] satisfies ChunkInput[]);
|
|
|
|
await pgliteEngine.putPage('test/parity-fixture', {
|
|
type: 'note',
|
|
title: 'parity test fixture',
|
|
compiled_truth: 'parity test fixture content',
|
|
timeline: '',
|
|
});
|
|
await pgliteEngine.upsertChunks('test/parity-fixture', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'parity test fixture content',
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(20),
|
|
token_count: 5,
|
|
}] satisfies ChunkInput[]);
|
|
|
|
const pgDefault = await pgEngine.searchKeyword('parity test fixture');
|
|
const pgliteDefault = await pgliteEngine.searchKeyword('parity test fixture');
|
|
expect(pgDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
|
|
expect(pgliteDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
|
|
|
|
const pgOptIn = await pgEngine.searchKeyword('parity test fixture', {
|
|
include_slug_prefixes: ['test/'],
|
|
});
|
|
const pgliteOptIn = await pgliteEngine.searchKeyword('parity test fixture', {
|
|
include_slug_prefixes: ['test/'],
|
|
});
|
|
expect(pgOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
|
|
expect(pgliteOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
|
|
});
|
|
|
|
test('detail=high produces a different ranking than default on at least one engine', async () => {
|
|
// Source-boost gates on `detail !== 'high'`. If the gate works on both
|
|
// engines, the ordering for `detail=high` should differ from default in
|
|
// any case where the swamp / curated pages have different raw scores.
|
|
//
|
|
// Postgres's CTE ranks pages then picks best chunk; ts_rank normalizes
|
|
// by doc length so chat pages don't always swamp at the page level.
|
|
// PGLite scores chunks directly — chat chunks beat article chunks on
|
|
// raw ts_rank. The two engines need different parity contracts here.
|
|
//
|
|
// Common assertion that holds on both: detail=high must include the
|
|
// chat pages in its result set (they're not filtered by detail), and
|
|
// the result set should not be identical to default-detail (the boost
|
|
// must be doing _something_ visible).
|
|
const pgDefault = await pgEngine.searchKeyword('fat code thin harness', { limit: 5 });
|
|
const pgHigh = await pgEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
|
|
const pgliteDefault = await pgliteEngine.searchKeyword('fat code thin harness', { limit: 5 });
|
|
const pgliteHigh = await pgliteEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
|
|
|
|
// Chat pages must be present in detail=high results on both engines.
|
|
expect(pgHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
|
|
expect(pgliteHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
|
|
|
|
// The boost must be doing something — at least one engine's ordering
|
|
// should change between default and detail=high.
|
|
const pgChanged = pgDefault.map((r: SearchResult) => r.slug).join(',') !== pgHigh.map((r: SearchResult) => r.slug).join(',');
|
|
const pgliteChanged = pgliteDefault.map((r: SearchResult) => r.slug).join(',') !== pgliteHigh.map((r: SearchResult) => r.slug).join(',');
|
|
expect(pgChanged || pgliteChanged).toBe(true);
|
|
});
|
|
|
|
// v0.39.3.0 T3 — provenance write+read parity (WARN-8 + CV5).
|
|
// Both engines must write the same 4 provenance columns (source_kind,
|
|
// source_uri, ingested_via, ingested_at) on putPage AND surface them
|
|
// on getPage. A drift here would mean `gbrain migrate --to supabase`
|
|
// silently loses half a user's provenance audit trail.
|
|
test('provenance columns: putPage writes + getPage returns identical shape on both engines', async () => {
|
|
const slug = 'wiki/provenance-parity';
|
|
const input = {
|
|
type: 'note' as const,
|
|
title: 'Provenance Parity Test',
|
|
compiled_truth: 'body',
|
|
timeline: '',
|
|
source_kind: 'capture-cli',
|
|
source_uri: 'file:///tmp/parity.md',
|
|
ingested_via: 'put_page',
|
|
};
|
|
await pgEngine.putPage(slug, input);
|
|
await pgliteEngine.putPage(slug, input);
|
|
|
|
const pgPage = await pgEngine.getPage(slug);
|
|
const pglitePage = await pgliteEngine.getPage(slug);
|
|
|
|
expect(pgPage).not.toBeNull();
|
|
expect(pglitePage).not.toBeNull();
|
|
|
|
// All 4 provenance fields must match across engines.
|
|
expect(pgPage!.source_kind).toBe('capture-cli');
|
|
expect(pglitePage!.source_kind).toBe('capture-cli');
|
|
expect(pgPage!.source_uri).toBe('file:///tmp/parity.md');
|
|
expect(pglitePage!.source_uri).toBe('file:///tmp/parity.md');
|
|
expect(pgPage!.ingested_via).toBe('put_page');
|
|
expect(pglitePage!.ingested_via).toBe('put_page');
|
|
// ingested_at is server-stamped; both engines must populate a Date
|
|
// (not Date drift across engines — the assertion is structural).
|
|
expect(pgPage!.ingested_at).toBeInstanceOf(Date);
|
|
expect(pglitePage!.ingested_at).toBeInstanceOf(Date);
|
|
});
|
|
|
|
test('provenance COALESCE-preserve UPDATE: parity on both engines (CV12)', async () => {
|
|
// First write with provenance.
|
|
const slug = 'wiki/provenance-preserve-parity';
|
|
await pgEngine.putPage(slug, {
|
|
type: 'note',
|
|
title: 'V1',
|
|
compiled_truth: 'body v1',
|
|
timeline: '',
|
|
source_kind: 'capture-cli',
|
|
ingested_via: 'put_page',
|
|
});
|
|
await pgliteEngine.putPage(slug, {
|
|
type: 'note',
|
|
title: 'V1',
|
|
compiled_truth: 'body v1',
|
|
timeline: '',
|
|
source_kind: 'capture-cli',
|
|
ingested_via: 'put_page',
|
|
});
|
|
|
|
// Second write WITHOUT provenance — both engines must preserve
|
|
// the first-write audit trail via COALESCE-preserve UPDATE.
|
|
await pgEngine.putPage(slug, {
|
|
type: 'note',
|
|
title: 'V2',
|
|
compiled_truth: 'body v2',
|
|
timeline: '',
|
|
});
|
|
await pgliteEngine.putPage(slug, {
|
|
type: 'note',
|
|
title: 'V2',
|
|
compiled_truth: 'body v2',
|
|
timeline: '',
|
|
});
|
|
|
|
const pgPage = await pgEngine.getPage(slug);
|
|
const pglitePage = await pgliteEngine.getPage(slug);
|
|
|
|
// Provenance preserved on BOTH engines (CV12 first-write-wins).
|
|
expect(pgPage!.source_kind).toBe('capture-cli');
|
|
expect(pglitePage!.source_kind).toBe('capture-cli');
|
|
expect(pgPage!.ingested_via).toBe('put_page');
|
|
expect(pglitePage!.ingested_via).toBe('put_page');
|
|
// Page title updated (proves the UPDATE actually fired).
|
|
expect(pgPage!.title).toBe('V2');
|
|
expect(pglitePage!.title).toBe('V2');
|
|
});
|
|
|
|
test('v0.41.19.0 deletePages parity: both engines return same confirmed-deleted slugs', async () => {
|
|
const realSlugs = ['wiki/dpp-1', 'wiki/dpp-2', 'wiki/dpp-3'];
|
|
for (const slug of realSlugs) {
|
|
await pgEngine.putPage(slug, {
|
|
type: 'note', title: slug, compiled_truth: 'body', timeline: '',
|
|
});
|
|
await pgliteEngine.putPage(slug, {
|
|
type: 'note', title: slug, compiled_truth: 'body', timeline: '',
|
|
});
|
|
}
|
|
|
|
// Mix real + ghost slugs. D6: only real ones come back.
|
|
const allSlugs = [...realSlugs, 'wiki/dpp-ghost-a', 'wiki/dpp-ghost-b'];
|
|
const pgDeleted = await pgEngine.deletePages(allSlugs, { sourceId: 'default' });
|
|
const pgliteDeleted = await pgliteEngine.deletePages(allSlugs, { sourceId: 'default' });
|
|
|
|
expect(pgDeleted.sort()).toEqual(realSlugs.sort());
|
|
expect(pgliteDeleted.sort()).toEqual(realSlugs.sort());
|
|
|
|
// Pages actually gone on both engines.
|
|
for (const slug of realSlugs) {
|
|
const pg = await pgEngine.getPage(slug);
|
|
const pglite = await pgliteEngine.getPage(slug);
|
|
expect(pg).toBeNull();
|
|
expect(pglite).toBeNull();
|
|
}
|
|
});
|
|
|
|
test('v0.41.19.0 resolveSlugsByPaths parity: same Map on both engines', async () => {
|
|
const seedSql = `
|
|
INSERT INTO pages (source_id, slug, source_path, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('default', $1, $2, 'note', 't', 'b', '', '{}'::jsonb)
|
|
ON CONFLICT (source_id, slug) DO UPDATE SET source_path = EXCLUDED.source_path
|
|
`;
|
|
await pgEngine.executeRaw(seedSql, ['wiki/rsp-1', 'wiki/rsp-1.md']);
|
|
await pgEngine.executeRaw(seedSql, ['wiki/rsp-2', 'wiki/rsp-2.md']);
|
|
await pgliteEngine.executeRaw(seedSql, ['wiki/rsp-1', 'wiki/rsp-1.md']);
|
|
await pgliteEngine.executeRaw(seedSql, ['wiki/rsp-2', 'wiki/rsp-2.md']);
|
|
|
|
const paths = ['wiki/rsp-1.md', 'wiki/rsp-2.md', 'wiki/rsp-missing.md'];
|
|
const pgMap = await pgEngine.resolveSlugsByPaths(paths, { sourceId: 'default' });
|
|
const pgliteMap = await pgliteEngine.resolveSlugsByPaths(paths, { sourceId: 'default' });
|
|
|
|
expect(pgMap.size).toBe(2);
|
|
expect(pgliteMap.size).toBe(2);
|
|
expect(pgMap.get('wiki/rsp-1.md')).toBe('wiki/rsp-1');
|
|
expect(pgliteMap.get('wiki/rsp-1.md')).toBe('wiki/rsp-1');
|
|
expect(pgMap.get('wiki/rsp-2.md')).toBe('wiki/rsp-2');
|
|
expect(pgliteMap.get('wiki/rsp-2.md')).toBe('wiki/rsp-2');
|
|
expect(pgMap.get('wiki/rsp-missing.md')).toBeUndefined();
|
|
expect(pgliteMap.get('wiki/rsp-missing.md')).toBeUndefined();
|
|
});
|
|
|
|
// v0.41.29.0 — findOrphanPages source scoping parity. Real Postgres
|
|
// coverage for the postgres.js `sql` scalar fragment + `= ANY(${arr}::text[])`
|
|
// array binding (a documented footgun class — the jsonb double-encode saga).
|
|
// PGLite logic is pinned in test/orphans-source-scope.test.ts; this asserts
|
|
// the Postgres SQL produces the same scoped sets. Cross-source inbound
|
|
// (src-b → src-a) must NOT make the target an orphan of src-a (A2).
|
|
test('v0.41.29.0 findOrphanPages source scoping parity (scalar + federated)', async () => {
|
|
const srcSql = `INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb) ON CONFLICT DO NOTHING`;
|
|
const pageSql = `
|
|
INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ($1, $2, 'person', 't', 'b', '', '{}'::jsonb)
|
|
ON CONFLICT (source_id, slug) DO NOTHING
|
|
`;
|
|
for (const eng of [pgEngine, pgliteEngine]) {
|
|
await eng.executeRaw(srcSql, ['orphan-src-a']);
|
|
await eng.executeRaw(srcSql, ['orphan-src-b']);
|
|
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-orphan-a']);
|
|
await eng.executeRaw(pageSql, ['orphan-src-a', 'people/op-target-a']);
|
|
await eng.executeRaw(pageSql, ['orphan-src-b', 'people/op-linker-b']);
|
|
// Cross-source inbound: src-b page → src-a target (A2).
|
|
await eng.addLink(
|
|
'people/op-linker-b', 'people/op-target-a', '', 'mentions', 'markdown',
|
|
undefined, undefined, { fromSourceId: 'orphan-src-b', toSourceId: 'orphan-src-a' },
|
|
);
|
|
}
|
|
|
|
const scoped = async (eng: BrainEngine, opts: { sourceId?: string; sourceIds?: string[] }) =>
|
|
(await eng.findOrphanPages(opts)).map(r => r.slug).filter(s => s.startsWith('people/op-')).sort();
|
|
|
|
// Scalar scope to src-a: op-orphan-a is an orphan; op-target-a is saved
|
|
// by the cross-source inbound (A2). Parity on both engines.
|
|
const pgA = await scoped(pgEngine, { sourceId: 'orphan-src-a' });
|
|
const pgliteA = await scoped(pgliteEngine, { sourceId: 'orphan-src-a' });
|
|
expect(pgA).toEqual(['people/op-orphan-a']);
|
|
expect(pgliteA).toEqual(pgA);
|
|
|
|
// Scalar scope to src-b.
|
|
const pgB = await scoped(pgEngine, { sourceId: 'orphan-src-b' });
|
|
const pgliteB = await scoped(pgliteEngine, { sourceId: 'orphan-src-b' });
|
|
expect(pgB).toEqual(['people/op-linker-b']);
|
|
expect(pgliteB).toEqual(pgB);
|
|
|
|
// Federated array scope (= ANY binding) → union.
|
|
const pgFed = await scoped(pgEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
|
|
const pgliteFed = await scoped(pgliteEngine, { sourceIds: ['orphan-src-a', 'orphan-src-b'] });
|
|
expect(pgFed).toEqual(['people/op-linker-b', 'people/op-orphan-a']);
|
|
expect(pgliteFed).toEqual(pgFed);
|
|
});
|
|
});
|