diff --git a/src/core/schema-pack/manifest-v1.ts b/src/core/schema-pack/manifest-v1.ts index f27526105..74e7af81f 100644 --- a/src/core/schema-pack/manifest-v1.ts +++ b/src/core/schema-pack/manifest-v1.ts @@ -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([ diff --git a/src/core/schema-pack/retype.ts b/src/core/schema-pack/retype.ts index dce647590..db764099c 100644 --- a/src/core/schema-pack/retype.ts +++ b/src/core/schema-pack/retype.ts @@ -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; diff --git a/src/core/schema-pack/unify-types-handler.ts b/src/core/schema-pack/unify-types-handler.ts index 3956eee9e..c5a932fb2 100644 --- a/src/core/schema-pack/unify-types-handler.ts +++ b/src/core/schema-pack/unify-types-handler.ts @@ -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') { diff --git a/test/schema-pack-retype.test.ts b/test/schema-pack-retype.test.ts index 53d1b8000..e54b7f2fe 100644 --- a/test/schema-pack-retype.test.ts +++ b/test/schema-pack-retype.test.ts @@ -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)', () => {