mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
feat(schema-pack): add slug_filter to retype mapping rules (#2655)
retype mapping_rules' existing path_filter matches pages.source_path, which is only populated for pages synced from a git repo. Pages ingested via the put_page MCP tool (or any write path that doesn't go through sync) have source_path = NULL, so path_filter can never disambiguate them — a same-from_type retype rule targeting a slug prefix has no way to address this class of page at all. slug_filter adds an independent, orthogonal LIKE filter on pages.slug (the field that's always populated), combinable with path_filter via AND when both are given. Wired through the full call path: schema validation (manifest-v1.ts), the RetypeRule interface + probeRule/applyRetypeRule (retype.ts), and the pack-manifest → RetypeRule conversion in the unify-types job handler (unify-types-handler.ts) — the last one matters because a field only present in the zod schema but not carried through that conversion would validate fine yet silently no-op at execution time. - manifest-v1.ts: add optional slug_filter to RetypeMappingRuleSchema - retype.ts: slug_filter on RetypeRule; probeRule + applyRetypeRule both apply `AND slug LIKE $N` when present, independent of path_filter - unify-types-handler.ts: carry rule.slug_filter through the pack-mapping-rule → RetypeRule conversion - tests: skips-outside-slug_filter, matches-despite-NULL-source_path (the motivating case), path_filter+slug_filter combined via AND 320/320 existing schema-pack tests pass; 3 new tests added; tsc --noEmit clean. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
Time Attakc
parent
6d1232d5a6
commit
a68379050e
@@ -258,6 +258,7 @@ const RetypeMappingRuleSchema = z.object({
|
||||
subtype: z.string().optional(),
|
||||
subtype_field: z.enum(ALLOWED_SUBTYPE_FIELDS).default('subtype'),
|
||||
path_filter: z.string().optional(),
|
||||
slug_filter: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
const ResolverSchema = z.union([
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface RetypeRule {
|
||||
subtype_field?: AllowedSubtypeField;
|
||||
/** Optional source_path LIKE filter for disambiguation. */
|
||||
path_filter?: string;
|
||||
/** Optional slug LIKE filter for disambiguation. Independent of
|
||||
* path_filter (both may be given; combined with AND). Useful when
|
||||
* pages were ingested without a populated source_path (e.g. written
|
||||
* via the put_page MCP tool rather than synced from a git repo), where
|
||||
* path_filter can never match. */
|
||||
slug_filter?: string;
|
||||
}
|
||||
|
||||
export interface RetypeOpts {
|
||||
@@ -114,6 +120,7 @@ async function probeRule(
|
||||
engine: BrainEngine,
|
||||
fromType: string,
|
||||
pathFilter: string | undefined,
|
||||
slugFilter: string | undefined,
|
||||
sourceId: string | undefined,
|
||||
): Promise<{ count: number; sample: string[] }> {
|
||||
// The catch-all sentinel uses a special "not in pack types" probe; for now
|
||||
@@ -129,6 +136,10 @@ async function probeRule(
|
||||
where += ` AND source_path LIKE $${params.length + 1}`;
|
||||
params.push(pathFilter);
|
||||
}
|
||||
if (slugFilter) {
|
||||
where += ` AND slug LIKE $${params.length + 1}`;
|
||||
params.push(slugFilter);
|
||||
}
|
||||
if (sourceId) {
|
||||
where += ` AND source_id = $${params.length + 1}`;
|
||||
params.push(sourceId);
|
||||
@@ -178,6 +189,10 @@ async function applyRetypeRule(
|
||||
winWhereParts.push(`source_path LIKE $${winParams.length + 1}`);
|
||||
winParams.push(rule.path_filter);
|
||||
}
|
||||
if (rule.slug_filter) {
|
||||
winWhereParts.push(`slug LIKE $${winParams.length + 1}`);
|
||||
winParams.push(rule.slug_filter);
|
||||
}
|
||||
if (sourceId) {
|
||||
winWhereParts.push(`source_id = $${winParams.length + 1}`);
|
||||
winParams.push(sourceId);
|
||||
@@ -313,6 +328,7 @@ export async function runRetypeCore(
|
||||
ctx.engine,
|
||||
rule.from_type,
|
||||
rule.path_filter,
|
||||
rule.slug_filter,
|
||||
sourceId,
|
||||
);
|
||||
let applied = 0;
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function runUnifyTypes(
|
||||
subtype: rule.subtype,
|
||||
subtype_field: rule.subtype_field,
|
||||
path_filter: rule.path_filter,
|
||||
slug_filter: rule.slug_filter,
|
||||
});
|
||||
}
|
||||
} else if (rule.kind === 'page_to_link') {
|
||||
|
||||
@@ -188,6 +188,64 @@ describe('runRetypeCore', () => {
|
||||
);
|
||||
expect(rows[0].type).toBe('tweet-single');
|
||||
});
|
||||
|
||||
it('skips pages outside the slug_filter', async () => {
|
||||
await seed('tweets/a', 'tweet-single');
|
||||
await seed('other/b', 'tweet-single');
|
||||
const result = await runRetypeCore(ctxOf(), {
|
||||
rules: [{ from_type: 'tweet-single', to_type: 'tweet', slug_filter: 'tweets/%' }],
|
||||
apply: true,
|
||||
});
|
||||
expect(result.total_applied).toBe(1);
|
||||
const rows = await engine.executeRaw<{ slug: string; type: string }>(
|
||||
`SELECT slug, type FROM pages WHERE slug LIKE '%/%' ORDER BY slug`,
|
||||
);
|
||||
expect(rows.find((r) => r.slug === 'tweets/a')?.type).toBe('tweet');
|
||||
expect(rows.find((r) => r.slug === 'other/b')?.type).toBe('tweet-single');
|
||||
});
|
||||
|
||||
it('matches slug_filter even when source_path is NULL (put_page-ingested pages)', async () => {
|
||||
// Pages written via the put_page MCP tool (vs. synced from a git repo)
|
||||
// never get a source_path — this is the exact gap slug_filter closes.
|
||||
await engine.putPage('tweets/a', {
|
||||
title: 'tweets/a',
|
||||
type: 'tweet-single' as never,
|
||||
compiled_truth: 'body that exceeds minimum length to pass any backstop guards we may have around content here',
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
source_path: null as never,
|
||||
});
|
||||
const dryRun = await runRetypeCore(ctxOf(), {
|
||||
rules: [{ from_type: 'tweet-single', to_type: 'tweet', path_filter: 'tweets/%' }],
|
||||
apply: false,
|
||||
});
|
||||
expect(dryRun.per_rule[0].would_apply).toBe(0); // path_filter can't match: source_path is NULL
|
||||
const result = await runRetypeCore(ctxOf(), {
|
||||
rules: [{ from_type: 'tweet-single', to_type: 'tweet', slug_filter: 'tweets/%' }],
|
||||
apply: true,
|
||||
});
|
||||
expect(result.total_applied).toBe(1); // slug_filter matches regardless of source_path
|
||||
});
|
||||
|
||||
it('combines path_filter AND slug_filter when both given', async () => {
|
||||
await seed('tweets/a', 'tweet-single', { sourcePath: 'tweets/a.md' });
|
||||
await seed('tweets/b', 'tweet-single', { sourcePath: 'archive/tweets-b.md' });
|
||||
const result = await runRetypeCore(ctxOf(), {
|
||||
rules: [{
|
||||
from_type: 'tweet-single',
|
||||
to_type: 'tweet',
|
||||
path_filter: 'tweets/%',
|
||||
slug_filter: 'tweets/%',
|
||||
}],
|
||||
apply: true,
|
||||
});
|
||||
// Only tweets/a matches BOTH filters (tweets/b's source_path is under archive/).
|
||||
expect(result.total_applied).toBe(1);
|
||||
const rows = await engine.executeRaw<{ type: string }>(
|
||||
`SELECT type FROM pages WHERE slug = 'tweets/b'`,
|
||||
);
|
||||
expect(rows[0].type).toBe('tweet-single');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtype_field allowlist (D9)', () => {
|
||||
|
||||
Reference in New Issue
Block a user