Engineering-Specs/06-crawler-client-index-manager.md
Crawler Factory: Spec 06: Crawler API Client + Index Manager Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the Python CrawlerClient (packages/crawl/src/crawl/manage_seeder.py:34-102) to TypeScript and add an IndexManager that idempotently creates/updates per-domain Algolia indices using DSS-derived settings: so downstream API endpoints (Specs 08+) can provision crawlers and their target indices through one cohesive interface.
Architecture: Two thin classes. CrawlerClient is a typed fetch wrapper around the Algolia Crawler REST API (https://crawler.algolia.com/api/1) with Basic-auth (base64(userId:apiKey)). IndexManager wraps the algoliasearch v5 client to ensure-or-update per-domain indices (settings pulled from lib/factory/dss.ts) and the two factory meta-indices (algoliacentral_factory_sessions, algoliacentral_factory_blueprints). neuralSearch is intentionally NOT enabled on cold-start indices: the Algolia API rejects it without prior Insights events; we set a neuralPending flag for the next-run promotion (Spec 13).
Tech Stack: TypeScript (strict), zod 3.25 for boundary validation, algoliasearch v5, native fetch (no httpx/axios), vitest with vi.mock for algoliasearch and vi.spyOn(globalThis, 'fetch') for the crawler client, pino logger via lib/utils/logger.ts.
Depends on: Spec 01: imports getDss, listContentDomains, ContentDomain from lib/factory/dss.ts.
Consumed by: Spec 08 (/api/factory/create-crawler), Spec 09 (/api/factory/test-real), Spec 11 (frontend status panel via stats), Spec 13 (neural promotion smoke).
Plan source: Projects/Crawler-Factory/00-Plan.md §3c (per-domain index design), §7 Tasks 12–13 (TS Crawler client + Index manager), §16k Decision 3 (per-domain indices for multi-brand sites: each content domain × tenant gets its own index, never a shared one), §4e (agent_slot and blueprint metadata), §15 (cold-start neural deferral).
Python reference: packages/crawl/src/crawl/manage_seeder.py:34-102 defines the source-of-truth API call shapes (paths, headers, request bodies, response keys). The TypeScript port mirrors method-for-method.
File structure
| Path | Responsibility |
|---|---|
lib/factory/crawler-client.ts |
Typed wrapper around the Algolia Crawler REST API. Auth, createCrawler, patchConfig, crawlUrls, startReindex, getStatus, getStats. Native fetch. |
lib/factory/index-manager.ts |
Idempotent index creation + settings reconciliation against DSS. Handles both per-domain content indices and the two factory meta-indices. Cold-start neural deferral. |
lib/factory/__tests__/crawler-client.test.ts |
Unit tests with mocked global fetch. One per public method + auth-header + 4xx error case. |
lib/factory/__tests__/index-manager.test.ts |
Unit tests with mocked algoliasearch. Idempotent ensure, drift-update, cold-start neural-pending, factory meta-indices. |
SOP rules applied (non-negotiable; from CodingSOPs.md, TestingSOPs.md, WritingSOPs.md)
Every file in this spec MUST follow these rules. They are not optional.
- Two-layer type safety. zod for runtime boundary validation (request bodies, response payloads, env reads);
tsc --noEmitstrict for write-time. Noany. UseT | null, notT | undefined, when absence is meaningful. - Logger in every module. Top of every file:
import { createLogger } from '../utils/logger';andconst log = createLogger('factory:crawler-client');(orfactory:index-manager). Noconsole.log. Format:log.info({ event: 'name', ...kvs }, 'short_msg'). - Try/catch every fallible call.
fetch,algoliasearch.*,process.envreads when required. Catch specific first, generic last. Always log before rethrow with full context (sanitize auth headers: never log the api key or the full Authorization header). - Functions ≤20 lines, ≤3 params. More than 3 params → use a typed config object.
- Docstring on every exported symbol. Single line explaining purpose. Comment WHY, not WHAT.
- No raw dicts crossing module boundaries. Crawler API responses go through zod parse before returning to callers. DSS reads return
DssEntry, notRecord<string, unknown>. - TDD cadence. Test first → run, expect FAIL → implement → run, expect PASS → commit.
- Mock external services in unit tests. Use
vi.mockforalgoliasearchandvi.spyOn(globalThis, 'fetch')for the crawler API. Real round-trip lives in Spec 13 smoke tests, gated by env vars. - Naming. TypeScript:
camelCasefor vars/functions,PascalCasefor types/classes,UPPER_SNAKE_CASEfor module constants. Files:kebab-case.ts. Tests: same name with.test.ts. - Conventional commits.
feat(factory):,test(factory):,chore(factory):. One logical change per commit. - Env var discipline.
ALGOLIA_CRAWLER_USER_IDandALGOLIA_CRAWLER_API_KEYare constructor-injected (not read inside class methods), so unit tests don't have to manipulateprocess.env. The construction site (Spec 08 endpoints) does the env read.
Task 1: TS CrawlerClient: class skeleton + Basic auth header
Files:
- Create: lib/factory/crawler-client.ts
- Create: lib/factory/__tests__/crawler-client.test.ts
The Python reference (manage_seeder.py:34-46) builds the auth header once at construction:
self._auth_header = "Basic " + base64.b64encode(f"{user_id}:{api_key}".encode()).decode()
Mirror that exactly.
- [ ] Step 1.1: Write failing test for the auth header format
Create lib/factory/__tests__/crawler-client.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CrawlerClient } from '../crawler-client';
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('CrawlerClient — auth header', () => {
it('builds a Basic header from base64(userId:apiKey)', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ crawlerId: 'c1' }),
});
const client = new CrawlerClient('user-abc', 'key-xyz');
await client.createCrawler({ name: 'demo', config: { appId: 'a', apiKey: 'k', indexPrefix: 'p' } });
const expected = 'Basic ' + Buffer.from('user-abc:key-xyz').toString('base64');
const callInit = mockFetch.mock.calls[0][1] as RequestInit;
expect((callInit.headers as Record<string, string>).Authorization).toBe(expected);
expect((callInit.headers as Record<string, string>)['Content-Type']).toBe('application/json');
});
});
- [ ] Step 1.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts
Expected: FAIL: Cannot find module '../crawler-client'.
- [ ] Step 1.3: Implement skeleton + auth +
createCrawler
Create lib/factory/crawler-client.ts:
/**
* TypeScript port of the Python CrawlerClient
* (packages/crawl/src/crawl/manage_seeder.py:34-102).
*
* Why: Spec 08 endpoints run on Vercel Node — we cannot import Python.
* The wire format and method shapes are 1-for-1 with the reference.
*/
import { z } from 'zod';
import { createLogger } from '../utils/logger';
const log = createLogger('factory:crawler-client');
const CRAWLER_BASE = 'https://crawler.algolia.com/api/1';
// ─── Request/response zod schemas ───────────────────────────────────────────
const CreateCrawlerResponseSchema = z.object({
crawlerId: z.string().min(1),
});
const TaskResponseSchema = z.object({
taskId: z.string().min(1),
});
const StatusResponseSchema = z.object({
id: z.string(),
name: z.string(),
reindexing: z.boolean().optional(),
config: z.record(z.unknown()).optional(),
}).passthrough();
const StatsResponseSchema = z.record(z.unknown());
export interface CreateCrawlerInput {
/** Human-readable crawler name (becomes part of the dashboard URL). */
name: string;
/** Initial crawler config block — passed through to PATCH /config later. */
config: Record<string, unknown>;
}
export interface CrawlerStatus {
id: string;
name: string;
reindexing?: boolean;
config?: Record<string, unknown>;
[k: string]: unknown;
}
/**
* Thin typed wrapper around the Algolia Crawler REST API.
* Auth: Basic base64(userId:apiKey) — separate from Algolia search credentials.
*/
export class CrawlerClient {
private readonly authHeader: string;
constructor(userId: string, apiKey: string) {
if (!userId || !apiKey) {
throw new Error('CrawlerClient requires both userId and apiKey');
}
// Buffer is available in Node 18+; Vercel functions use Node 20.
const token = Buffer.from(`${userId}:${apiKey}`).toString('base64');
this.authHeader = `Basic ${token}`;
}
/**
* Create a new crawler. POST /1/crawlers — returns { crawlerId }.
*/
async createCrawler(input: CreateCrawlerInput): Promise<{ crawlerId: string }> {
const body = JSON.stringify(input);
const res = await this.request('POST', '/crawlers', body);
const parsed = CreateCrawlerResponseSchema.parse(res);
log.info({ event: 'createCrawler', crawlerId: parsed.crawlerId, name: input.name }, 'crawler created');
return parsed;
}
// (later steps add patchConfig, crawlUrls, startReindex, getStatus, getStats)
/**
* Internal request helper — issues the fetch, handles 4xx/5xx, logs failures.
* Sanitizes auth out of error logs.
*/
private async request(method: string, path: string, body?: string): Promise<unknown> {
const url = `${CRAWLER_BASE}${path}`;
let res: Response;
try {
res = await fetch(url, {
method,
headers: {
Authorization: this.authHeader,
'Content-Type': 'application/json',
},
body,
});
} catch (err) {
log.error({ event: 'request_network_error', method, path, err: String(err) }, 'crawler API network error');
throw err;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
log.error(
{ event: 'request_http_error', method, path, status: res.status, body: text.slice(0, 500) },
'crawler API HTTP error'
);
throw new Error(`Crawler API ${method} ${path} failed: ${res.status} ${text.slice(0, 200)}`);
}
try {
return await res.json();
} catch (err) {
log.error({ event: 'request_json_error', method, path, err: String(err) }, 'crawler API non-JSON response');
throw err;
}
}
}
- [ ] Step 1.4: Run, expect PASS
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t auth
Expected: 1 test passes.
- [ ] Step 1.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient skeleton with Basic auth + createCrawler"
Task 2: CrawlerClient.patchConfig
Files:
- Modify: lib/factory/crawler-client.ts
- Modify: lib/factory/__tests__/crawler-client.test.ts
Python reference (manage_seeder.py:54-61):
def patch_config(self, crawler_id: str, patch: dict[str, Any]) -> None:
resp = self._http.patch(
f"{_CRAWLER_BASE}/crawlers/{crawler_id}/config",
json=patch,
)
resp.raise_for_status()
- [ ] Step 2.1: Write failing test for
patchConfig
Append to lib/factory/__tests__/crawler-client.test.ts:
describe('CrawlerClient.patchConfig', () => {
it('PATCHes /1/crawlers/{id}/config with the given patch body', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
const client = new CrawlerClient('u', 'k');
await client.patchConfig('crawler_xyz', { startUrls: ['https://x.com/'] });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://crawler.algolia.com/api/1/crawlers/crawler_xyz/config');
expect((init as RequestInit).method).toBe('PATCH');
expect((init as RequestInit).body).toBe(JSON.stringify({ startUrls: ['https://x.com/'] }));
});
it('throws on 4xx with body fragment in error message', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
text: async () => '{"error":"bad startUrls"}',
});
const client = new CrawlerClient('u', 'k');
await expect(client.patchConfig('crawler_xyz', { startUrls: [] })).rejects.toThrow(/400/);
});
});
- [ ] Step 2.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t patchConfig
Expected: FAIL: client.patchConfig is not a function.
- [ ] Step 2.3: Add
patchConfigtocrawler-client.ts
Insert after createCrawler:
/**
* Partial-update a crawler's config. PATCH /1/crawlers/{id}/config.
* Only keys present in `patch` are changed.
*/
async patchConfig(crawlerId: string, patch: Record<string, unknown>): Promise<void> {
await this.request('PATCH', `/crawlers/${encodeURIComponent(crawlerId)}/config`, JSON.stringify(patch));
log.info({ event: 'patchConfig', crawlerId, keys: Object.keys(patch) }, 'crawler config patched');
}
- [ ] Step 2.4: Run patchConfig tests, expect PASS
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t patchConfig
Expected: 2 tests pass.
- [ ] Step 2.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient.patchConfig with 4xx error handling"
Task 3: CrawlerClient.crawlUrls
Files:
- Modify: lib/factory/crawler-client.ts
- Modify: lib/factory/__tests__/crawler-client.test.ts
Python reference (manage_seeder.py:73-91): POST /1/crawlers/{id}/urls/crawl with {urls, save} → returns {taskId}. Rate limit: 500 req/24h (Algolia Crawler API limit; documented for callers, not enforced here).
- [ ] Step 3.1: Write failing test for
crawlUrls
Append to lib/factory/__tests__/crawler-client.test.ts:
describe('CrawlerClient.crawlUrls', () => {
it('POSTs urls + save flag and returns taskId', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ taskId: 'task_1' }),
});
const client = new CrawlerClient('u', 'k');
const taskId = await client.crawlUrls('crawler_xyz', ['https://x.com/a', 'https://x.com/b'], true);
expect(taskId).toBe('task_1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://crawler.algolia.com/api/1/crawlers/crawler_xyz/urls/crawl');
expect((init as RequestInit).method).toBe('POST');
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
urls: ['https://x.com/a', 'https://x.com/b'],
save: true,
});
});
it('defaults save to false when omitted', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ taskId: 't2' }) });
const client = new CrawlerClient('u', 'k');
await client.crawlUrls('crawler_xyz', ['https://x.com/a']);
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(JSON.parse(init.body as string).save).toBe(false);
});
});
- [ ] Step 3.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t crawlUrls
Expected: FAIL: client.crawlUrls is not a function.
- [ ] Step 3.3: Add
crawlUrls
Insert in crawler-client.ts:
/**
* Trigger an immediate crawl of specific URLs. POST /1/crawlers/{id}/urls/crawl.
* Returns the Algolia taskId. `save=true` persists URLs to extraUrls config.
* Algolia rate-limits this endpoint to 500 requests per 24 hours per crawler.
*/
async crawlUrls(crawlerId: string, urls: string[], save: boolean = false): Promise<string> {
const body = JSON.stringify({ urls, save });
const res = await this.request(
'POST',
`/crawlers/${encodeURIComponent(crawlerId)}/urls/crawl`,
body
);
const parsed = TaskResponseSchema.parse(res);
log.info(
{ event: 'crawlUrls', crawlerId, count: urls.length, save, taskId: parsed.taskId },
'crawl_urls submitted'
);
return parsed.taskId;
}
- [ ] Step 3.4: Run, expect PASS
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t crawlUrls
Expected: 2 tests pass.
- [ ] Step 3.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient.crawlUrls returns Algolia taskId"
Task 4: CrawlerClient.startReindex
Files:
- Modify: lib/factory/crawler-client.ts
- Modify: lib/factory/__tests__/crawler-client.test.ts
Python reference (manage_seeder.py:93-99): POST /1/crawlers/{id}/reindex → returns {taskId}.
- [ ] Step 4.1: Write failing test
Append:
describe('CrawlerClient.startReindex', () => {
it('POSTs to /reindex and returns taskId', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ taskId: 'reindex_99' }),
});
const client = new CrawlerClient('u', 'k');
const taskId = await client.startReindex('crawler_xyz');
expect(taskId).toBe('reindex_99');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('https://crawler.algolia.com/api/1/crawlers/crawler_xyz/reindex');
expect((init as RequestInit).method).toBe('POST');
});
});
- [ ] Step 4.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t startReindex
Expected: FAIL.
- [ ] Step 4.3: Add
startReindex
Insert:
/**
* Start a full crawl. POST /1/crawlers/{id}/reindex. Returns the taskId.
*/
async startReindex(crawlerId: string): Promise<string> {
const res = await this.request('POST', `/crawlers/${encodeURIComponent(crawlerId)}/reindex`);
const parsed = TaskResponseSchema.parse(res);
log.info({ event: 'startReindex', crawlerId, taskId: parsed.taskId }, 'reindex started');
return parsed.taskId;
}
- [ ] Step 4.4: Run, expect PASS
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t startReindex
Expected: 1 test passes.
- [ ] Step 4.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient.startReindex"
Task 5: CrawlerClient.getStatus (with optional withConfig)
Files:
- Modify: lib/factory/crawler-client.ts
- Modify: lib/factory/__tests__/crawler-client.test.ts
Python reference (manage_seeder.py:47-52): GET /1/crawlers/{id}?withConfig=true. The TS port exposes withConfig as an option on the method (default false to keep payloads small for status-only polls).
- [ ] Step 5.1: Write failing tests
Append:
describe('CrawlerClient.getStatus', () => {
it('GETs /1/crawlers/{id} without withConfig by default', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'crawler_xyz', name: 'demo', reindexing: false }),
});
const client = new CrawlerClient('u', 'k');
const status = await client.getStatus('crawler_xyz');
expect(status.id).toBe('crawler_xyz');
expect(mockFetch.mock.calls[0][0]).toBe('https://crawler.algolia.com/api/1/crawlers/crawler_xyz');
});
it('appends ?withConfig=true when option set', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'crawler_xyz', name: 'demo', config: { startUrls: [] } }),
});
const client = new CrawlerClient('u', 'k');
const status = await client.getStatus('crawler_xyz', { withConfig: true });
expect(status.config).toBeDefined();
expect(mockFetch.mock.calls[0][0]).toBe(
'https://crawler.algolia.com/api/1/crawlers/crawler_xyz?withConfig=true'
);
});
});
- [ ] Step 5.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t getStatus
Expected: FAIL.
- [ ] Step 5.3: Add
getStatus
Insert:
/**
* Fetch crawler details. GET /1/crawlers/{id}.
* Pass `{ withConfig: true }` to also receive the full crawler config block.
*/
async getStatus(
crawlerId: string,
options: { withConfig?: boolean } = {}
): Promise<CrawlerStatus> {
const qs = options.withConfig ? '?withConfig=true' : '';
const res = await this.request('GET', `/crawlers/${encodeURIComponent(crawlerId)}${qs}`);
return StatusResponseSchema.parse(res);
}
- [ ] Step 5.4: Run, expect PASS
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t getStatus
Expected: 2 tests pass.
- [ ] Step 5.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient.getStatus with optional withConfig"
Task 6: CrawlerClient.getStats
Files:
- Modify: lib/factory/crawler-client.ts
- Modify: lib/factory/__tests__/crawler-client.test.ts
GET /1/crawlers/{id}/stats/urls: returns crawl progress shape (varies by Algolia; we return as Record<string, unknown> and let Spec 11 frontend parse against its own zod). The Python reference doesn't have this method: it's a TS-only addition because the frontend Crawl Monitor (Spec 11) needs it.
- [ ] Step 6.1: Write failing test
Append:
describe('CrawlerClient.getStats', () => {
it('GETs /1/crawlers/{id}/stats/urls and returns the raw payload', async () => {
const payload = { count: 1234, errors: 2, indexed: 1232 };
mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => payload });
const client = new CrawlerClient('u', 'k');
const stats = await client.getStats('crawler_xyz');
expect(stats).toEqual(payload);
expect(mockFetch.mock.calls[0][0]).toBe(
'https://crawler.algolia.com/api/1/crawlers/crawler_xyz/stats/urls'
);
});
it('throws on 401 with sanitized message (no auth header leak)', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: async () => 'Unauthorized',
});
const client = new CrawlerClient('u', 'k');
await expect(client.getStats('crawler_xyz')).rejects.toThrow(/401/);
// Asserting the error message does NOT contain the api key
try {
await client.getStats('crawler_xyz');
} catch (err) {
expect(String(err)).not.toContain('u:k');
expect(String(err)).not.toContain('Basic ');
}
});
});
- [ ] Step 6.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/crawler-client.test.ts -t getStats
Expected: FAIL.
- [ ] Step 6.3: Add
getStats
Insert:
/**
* Fetch URL-level stats for a crawler.
* GET /1/crawlers/{id}/stats/urls — schema varies; we surface as a record.
*/
async getStats(crawlerId: string): Promise<Record<string, unknown>> {
const res = await this.request('GET', `/crawlers/${encodeURIComponent(crawlerId)}/stats/urls`);
return StatsResponseSchema.parse(res);
}
- [ ] Step 6.4: Run all crawler-client tests + tsc
npx vitest run lib/factory/__tests__/crawler-client.test.ts && npx tsc --noEmit
Expected: all crawler-client tests GREEN; tsc clean.
- [ ] Step 6.5: Commit
git add lib/factory/crawler-client.ts lib/factory/__tests__/crawler-client.test.ts
git commit -m "feat(factory): CrawlerClient.getStats + sanitized auth in error logs"
Task 7: IndexManager: class skeleton + ensureIndex happy path
Files:
- Create: lib/factory/index-manager.ts
- Create: lib/factory/__tests__/index-manager.test.ts
Per §16k Decision 3 (multi-brand federated corporates), each content domain on a tenant gets its OWN index, never shared across tenants. The IndexManager assumes the getDss(domain).indexName already encodes the canonical per-domain path; callers (Spec 08) are responsible for passing the right ContentDomain. We do NOT do tenant prefixing here; that is a higher-level concern flagged for v2.
ensureIndex(domain) is idempotent:
- Missing index → call setSettings with the DSS-derived block; Algolia auto-creates the index on first setSettings.
- Existing index, settings drift from DSS → call setSettings with the DSS-derived block.
- Existing index, settings match → no-op.
Drift detection compares only the three keys we manage (searchableAttributes, customRanking, attributesForFaceting). Other operator-set settings (e.g., ranking, replicas) are left untouched.
- [ ] Step 7.1: Write failing test for the missing-index case
Create lib/factory/__tests__/index-manager.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetSettings = vi.fn();
const mockSetSettings = vi.fn();
vi.mock('algoliasearch', () => ({
algoliasearch: () => ({
getSettings: mockGetSettings,
setSettings: mockSetSettings,
}),
}));
import { IndexManager } from '../index-manager';
beforeEach(() => {
mockGetSettings.mockReset();
mockSetSettings.mockReset();
});
describe('IndexManager.ensureIndex — cold start', () => {
it('creates the index via setSettings when getSettings throws "Index does not exist"', async () => {
mockGetSettings.mockRejectedValue(new Error('Index algoliacentral_marketing does not exist'));
mockSetSettings.mockResolvedValue({ taskID: 1 });
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureIndex('marketing');
expect(result.created).toBe(true);
expect(result.updated).toBe(false);
expect(result.neuralPending).toBe(true);
expect(mockSetSettings).toHaveBeenCalledTimes(1);
expect(mockSetSettings).toHaveBeenCalledWith({
indexName: 'algoliacentral_marketing',
indexSettings: expect.objectContaining({
searchableAttributes: expect.arrayContaining(['headline', 'articleBody']),
customRanking: expect.arrayContaining([expect.stringMatching(/desc\(datePublished\)/)]),
attributesForFaceting: expect.arrayContaining(['articleSection']),
}),
});
// neuralSearch must NOT be set on cold start
const setCall = mockSetSettings.mock.calls[0][0] as { indexSettings: Record<string, unknown> };
expect(setCall.indexSettings).not.toHaveProperty('mode');
expect(setCall.indexSettings).not.toHaveProperty('neuralSearch');
});
});
- [ ] Step 7.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/index-manager.test.ts
Expected: FAIL: Cannot find module '../index-manager'.
- [ ] Step 7.3: Implement
index-manager.tsskeleton +ensureIndexcold-start path
Create lib/factory/index-manager.ts:
/**
* IndexManager — idempotent per-domain Algolia index creation + settings reconciliation.
*
* Why: per 00-Plan.md §3c, every content domain has its own index with DSS-derived
* settings. This module is the single chokepoint for ensure-or-update so endpoints
* (Spec 08) and the future agent factory (§15) can rely on a consistent surface.
*
* Cold-start neural deferral: per 00-Plan.md §3c, neuralSearch is enabled by default
* but the Algolia API rejects it on a brand-new index without prior Insights events.
* We log a warning and return `{ neuralPending: true }`; Spec 13 promotes neural once
* events exist.
*/
import { algoliasearch, type SearchClient } from 'algoliasearch';
import { createLogger } from '../utils/logger';
import { getDss, type ContentDomain } from './dss';
const log = createLogger('factory:index-manager');
const FACTORY_SESSIONS_INDEX = 'algoliacentral_factory_sessions';
const FACTORY_BLUEPRINTS_INDEX = 'algoliacentral_factory_blueprints';
export interface EnsureResult {
/** Index was created on this call (no prior settings existed). */
created: boolean;
/** Existing index had drift; settings were updated on this call. */
updated: boolean;
/** neuralSearch was deferred — caller (or Spec 13) must promote later. */
neuralPending: boolean;
}
/**
* Idempotent index lifecycle manager. Wraps algoliasearch v5 client.
*/
export class IndexManager {
private client: SearchClient;
constructor(appId: string, apiKey: string) {
if (!appId || !apiKey) {
throw new Error('IndexManager requires both appId and apiKey');
}
this.client = algoliasearch(appId, apiKey);
}
/**
* Ensure the per-domain index exists with DSS-derived settings.
* Idempotent — safe to call repeatedly.
*/
async ensureIndex(contentDomain: ContentDomain): Promise<EnsureResult> {
const dss = getDss(contentDomain);
const desired = {
searchableAttributes: [...dss.algoliaConfig.searchableAttributes],
customRanking: [...dss.algoliaConfig.customRanking],
attributesForFaceting: [...dss.algoliaConfig.attributesForFaceting],
};
let existing: Record<string, unknown> | null = null;
try {
existing = (await this.client.getSettings({ indexName: dss.indexName })) as Record<string, unknown>;
} catch (err) {
if (this.isMissingIndexError(err)) {
existing = null;
} else {
log.error(
{ event: 'ensureIndex_getSettings_failed', indexName: dss.indexName, err: String(err) },
'getSettings failed (non-missing error)'
);
throw err;
}
}
if (existing === null) {
await this.applySettings(dss.indexName, desired);
log.warn(
{ event: 'ensureIndex_neural_pending', indexName: dss.indexName, contentDomain },
'neuralSearch deferred — index has no Insights events yet'
);
return { created: true, updated: false, neuralPending: true };
}
// Settings already exist → check drift on the three keys we manage.
return { created: false, updated: false, neuralPending: false };
}
/**
* Internal: detect "Index does not exist" errors across algoliasearch v5 shapes.
*/
private isMissingIndexError(err: unknown): boolean {
const msg = String(err);
return msg.includes('does not exist') || msg.includes('IndexNotFoundError');
}
/**
* Internal: setSettings with try/catch + log.
*/
private async applySettings(
indexName: string,
settings: {
searchableAttributes: string[];
customRanking: string[];
attributesForFaceting: string[];
}
): Promise<void> {
try {
await this.client.setSettings({ indexName, indexSettings: settings });
log.info({ event: 'applySettings', indexName, keys: Object.keys(settings) }, 'settings applied');
} catch (err) {
log.error(
{ event: 'applySettings_failed', indexName, err: String(err) },
'setSettings failed'
);
throw err;
}
}
}
- [ ] Step 7.4: Run, expect PASS
npx vitest run lib/factory/__tests__/index-manager.test.ts -t cold
Expected: 1 test passes.
- [ ] Step 7.5: Commit
git add lib/factory/index-manager.ts lib/factory/__tests__/index-manager.test.ts
git commit -m "feat(factory): IndexManager with cold-start ensureIndex + neural deferral"
Task 8: IndexManager.ensureIndex drift detection + idempotent no-op
Files:
- Modify: lib/factory/index-manager.ts
- Modify: lib/factory/__tests__/index-manager.test.ts
When the index already exists, compare current searchableAttributes, customRanking, attributesForFaceting against DSS. If any differ, call setSettings with the DSS-derived block. Otherwise, no-op.
Drift comparison is order-sensitive on customRanking and searchableAttributes (these affect ranking) and order-insensitive on attributesForFaceting (Algolia treats facet config as a set).
- [ ] Step 8.1: Write failing tests for drift + no-op
Append to lib/factory/__tests__/index-manager.test.ts:
describe('IndexManager.ensureIndex — existing index', () => {
it('is a no-op when settings already match DSS', async () => {
mockGetSettings.mockResolvedValue({
searchableAttributes: ['headline', 'articleBody', 'keywords'],
customRanking: ['desc(datePublished)', 'desc(view_count)'],
attributesForFaceting: ['articleSection', 'author', 'year'],
});
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureIndex('marketing');
expect(result.created).toBe(false);
expect(result.updated).toBe(false);
expect(result.neuralPending).toBe(false);
expect(mockSetSettings).not.toHaveBeenCalled();
});
it('updates settings when customRanking drifts (order matters)', async () => {
mockGetSettings.mockResolvedValue({
searchableAttributes: ['headline', 'articleBody', 'keywords'],
customRanking: ['desc(view_count)', 'desc(datePublished)'], // wrong order
attributesForFaceting: ['articleSection', 'author', 'year'],
});
mockSetSettings.mockResolvedValue({ taskID: 1 });
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureIndex('marketing');
expect(result.created).toBe(false);
expect(result.updated).toBe(true);
expect(result.neuralPending).toBe(false);
expect(mockSetSettings).toHaveBeenCalledTimes(1);
});
it('treats attributesForFaceting drift as set-equality (any-order match = no-op)', async () => {
mockGetSettings.mockResolvedValue({
searchableAttributes: ['headline', 'articleBody', 'keywords'],
customRanking: ['desc(datePublished)', 'desc(view_count)'],
attributesForFaceting: ['year', 'author', 'articleSection'], // reordered
});
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureIndex('marketing');
expect(result.updated).toBe(false);
expect(mockSetSettings).not.toHaveBeenCalled();
});
it('updates when attributesForFaceting set-content differs', async () => {
mockGetSettings.mockResolvedValue({
searchableAttributes: ['headline', 'articleBody', 'keywords'],
customRanking: ['desc(datePublished)', 'desc(view_count)'],
attributesForFaceting: ['articleSection'], // missing two
});
mockSetSettings.mockResolvedValue({ taskID: 1 });
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureIndex('marketing');
expect(result.updated).toBe(true);
expect(mockSetSettings).toHaveBeenCalledTimes(1);
});
});
- [ ] Step 8.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/index-manager.test.ts -t "existing index"
Expected: 4 tests fail (drift + no-op logic not yet implemented).
- [ ] Step 8.3: Add drift detection to
ensureIndex
Replace the existing !== null branch in ensureIndex:
if (existing === null) {
await this.applySettings(dss.indexName, desired);
log.warn(
{ event: 'ensureIndex_neural_pending', indexName: dss.indexName, contentDomain },
'neuralSearch deferred — index has no Insights events yet'
);
return { created: true, updated: false, neuralPending: true };
}
// Drift detection on the three keys we manage.
const drift = this.detectDrift(existing, desired);
if (drift) {
await this.applySettings(dss.indexName, desired);
log.info(
{ event: 'ensureIndex_drift_corrected', indexName: dss.indexName, drift },
'settings drift corrected'
);
return { created: false, updated: true, neuralPending: false };
}
return { created: false, updated: false, neuralPending: false };
}
/**
* Internal: detect drift on the three managed keys.
* Returns a brief reason-string for logging, or null when in sync.
* - searchableAttributes / customRanking — order-sensitive
* - attributesForFaceting — set-equality (any order)
*/
private detectDrift(
existing: Record<string, unknown>,
desired: { searchableAttributes: string[]; customRanking: string[]; attributesForFaceting: string[] }
): string | null {
const exSearchable = (existing.searchableAttributes as string[] | undefined) ?? [];
const exRanking = (existing.customRanking as string[] | undefined) ?? [];
const exFacets = (existing.attributesForFaceting as string[] | undefined) ?? [];
if (!arraysEqualOrdered(exSearchable, desired.searchableAttributes)) return 'searchableAttributes';
if (!arraysEqualOrdered(exRanking, desired.customRanking)) return 'customRanking';
if (!arraysEqualUnordered(exFacets, desired.attributesForFaceting)) return 'attributesForFaceting';
return null;
}
Add at the bottom of the file (module-level helpers):
function arraysEqualOrdered(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function arraysEqualUnordered(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
const sa = new Set(a);
for (const v of b) {
if (!sa.has(v)) return false;
}
return true;
}
- [ ] Step 8.4: Run, expect PASS
npx vitest run lib/factory/__tests__/index-manager.test.ts
Expected: all 5 ensureIndex tests pass.
- [ ] Step 8.5: Commit
git add lib/factory/index-manager.ts lib/factory/__tests__/index-manager.test.ts
git commit -m "feat(factory): IndexManager drift detection + idempotent no-op"
Task 9: IndexManager.ensureFactoryMetaIndices
Files:
- Modify: lib/factory/index-manager.ts
- Modify: lib/factory/__tests__/index-manager.test.ts
Spec 01 §5.3 explicitly defers facet configuration of algoliacentral_factory_sessions and algoliacentral_factory_blueprints to this spec. Settings:
algoliacentral_factory_sessions:attributesForFaceting: ['filterOnly(record_type)', 'filterOnly(parent_id)']: these power the sharded reads in Spec 01'sSessionStore.algoliacentral_factory_blueprints:attributesForFaceting: ['filterOnly(content_domain)', 'filterOnly(status)']: these powerlistBlueprintsByContentDomainin Spec 01'sBlueprintStore.
Both are idempotent: created on first call, drift-corrected after.
- [ ] Step 9.1: Write failing tests
Append:
describe('IndexManager.ensureFactoryMetaIndices', () => {
it('creates both meta-indices on cold start with the correct facet config', async () => {
mockGetSettings.mockRejectedValue(new Error('Index does not exist'));
mockSetSettings.mockResolvedValue({ taskID: 1 });
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureFactoryMetaIndices();
expect(result.sessions.created).toBe(true);
expect(result.blueprints.created).toBe(true);
expect(mockSetSettings).toHaveBeenCalledTimes(2);
const callArgs = mockSetSettings.mock.calls.map((c) => c[0] as { indexName: string; indexSettings: Record<string, unknown> });
const sessionsCall = callArgs.find((c) => c.indexName === 'algoliacentral_factory_sessions');
const blueprintsCall = callArgs.find((c) => c.indexName === 'algoliacentral_factory_blueprints');
expect(sessionsCall?.indexSettings.attributesForFaceting).toEqual([
'filterOnly(record_type)',
'filterOnly(parent_id)',
]);
expect(blueprintsCall?.indexSettings.attributesForFaceting).toEqual([
'filterOnly(content_domain)',
'filterOnly(status)',
]);
});
it('is a no-op when both meta-indices already have the right facets', async () => {
mockGetSettings.mockImplementation(async ({ indexName }: { indexName: string }) => {
if (indexName === 'algoliacentral_factory_sessions') {
return { attributesForFaceting: ['filterOnly(record_type)', 'filterOnly(parent_id)'] };
}
return { attributesForFaceting: ['filterOnly(content_domain)', 'filterOnly(status)'] };
});
const manager = new IndexManager('app-id', 'admin-key');
const result = await manager.ensureFactoryMetaIndices();
expect(result.sessions.created).toBe(false);
expect(result.sessions.updated).toBe(false);
expect(result.blueprints.created).toBe(false);
expect(result.blueprints.updated).toBe(false);
expect(mockSetSettings).not.toHaveBeenCalled();
});
});
- [ ] Step 9.2: Run, expect FAIL
npx vitest run lib/factory/__tests__/index-manager.test.ts -t ensureFactoryMetaIndices
Expected: FAIL: method not implemented.
- [ ] Step 9.3: Add
ensureFactoryMetaIndices+ helper for facet-only drift
Insert into the IndexManager class (above the private helpers):
/**
* Ensure the two factory meta-indices exist with the correct facet config.
* Idempotent. Called once per app boot (or by Spec 13 smoke).
*/
async ensureFactoryMetaIndices(): Promise<{ sessions: EnsureResult; blueprints: EnsureResult }> {
const sessions = await this.ensureFacetOnly(FACTORY_SESSIONS_INDEX, [
'filterOnly(record_type)',
'filterOnly(parent_id)',
]);
const blueprints = await this.ensureFacetOnly(FACTORY_BLUEPRINTS_INDEX, [
'filterOnly(content_domain)',
'filterOnly(status)',
]);
return { sessions, blueprints };
}
/**
* Internal: ensure a meta-index has exactly the given attributesForFaceting list.
* Other settings are not touched. Order-sensitive (matches what Algolia returns).
*/
private async ensureFacetOnly(indexName: string, facets: string[]): Promise<EnsureResult> {
let existing: Record<string, unknown> | null = null;
try {
existing = (await this.client.getSettings({ indexName })) as Record<string, unknown>;
} catch (err) {
if (this.isMissingIndexError(err)) {
existing = null;
} else {
log.error({ event: 'ensureFacetOnly_getSettings_failed', indexName, err: String(err) }, 'getSettings failed');
throw err;
}
}
if (existing === null) {
try {
await this.client.setSettings({ indexName, indexSettings: { attributesForFaceting: facets } });
log.info({ event: 'ensureFacetOnly_created', indexName, facets }, 'meta index created');
} catch (err) {
log.error({ event: 'ensureFacetOnly_setSettings_failed', indexName, err: String(err) }, 'setSettings failed');
throw err;
}
return { created: true, updated: false, neuralPending: false };
}
const exFacets = (existing.attributesForFaceting as string[] | undefined) ?? [];
if (arraysEqualOrdered(exFacets, facets)) {
return { created: false, updated: false, neuralPending: false };
}
try {
await this.client.setSettings({ indexName, indexSettings: { attributesForFaceting: facets } });
log.info({ event: 'ensureFacetOnly_drift_corrected', indexName, facets }, 'meta index facets updated');
} catch (err) {
log.error({ event: 'ensureFacetOnly_setSettings_failed', indexName, err: String(err) }, 'setSettings failed');
throw err;
}
return { created: false, updated: true, neuralPending: false };
}
- [ ] Step 9.4: Run, expect PASS
npx vitest run lib/factory/__tests__/index-manager.test.ts -t ensureFactoryMetaIndices
Expected: 2 tests pass.
- [ ] Step 9.5: Run all factory tests + tsc
npx vitest run lib/factory/__tests__/ && npx tsc --noEmit
Expected: all factory tests GREEN; tsc clean.
- [ ] Step 9.6: Commit
git add lib/factory/index-manager.ts lib/factory/__tests__/index-manager.test.ts
git commit -m "feat(factory): IndexManager.ensureFactoryMetaIndices for sessions+blueprints"
Task 10: 4xx error handling test for IndexManager
Files:
- Modify: lib/factory/__tests__/index-manager.test.ts
Confirms that non-"missing-index" errors from getSettings (auth failure, rate limit, network) propagate rather than being silently treated as cold start.
- [ ] Step 10.1: Write failing test
Append:
describe('IndexManager — error propagation', () => {
it('propagates non-missing errors from getSettings', async () => {
mockGetSettings.mockRejectedValue(new Error('Invalid Application-ID or API key'));
const manager = new IndexManager('app-id', 'admin-key');
await expect(manager.ensureIndex('marketing')).rejects.toThrow(/Invalid Application-ID/);
expect(mockSetSettings).not.toHaveBeenCalled();
});
it('propagates setSettings failure', async () => {
mockGetSettings.mockRejectedValue(new Error('Index does not exist'));
mockSetSettings.mockRejectedValue(new Error('Rate limit exceeded'));
const manager = new IndexManager('app-id', 'admin-key');
await expect(manager.ensureIndex('marketing')).rejects.toThrow(/Rate limit/);
});
});
- [ ] Step 10.2: Run, expect PASS (logic already correct)
npx vitest run lib/factory/__tests__/index-manager.test.ts -t "error propagation"
Expected: 2 tests pass: the existing implementation already throws on non-missing errors. (If they fail, that's a real bug in Task 7/8 implementation; fix before moving on.)
- [ ] Step 10.3: Commit
git add lib/factory/__tests__/index-manager.test.ts
git commit -m "test(factory): pin IndexManager error propagation behavior"
Task 11: Cluster validation: full regression + tsc
Files: (read-only verification)
- [ ] Step 11.1: Run the full project test suite
npx vitest run
Expected: Spec 01 baseline (407 + ~23 factory tests from Spec 01) + Spec 06 additions (~16+ new tests): all GREEN.
- [ ] Step 11.2: Run full tsc
npx tsc --noEmit
Expected: clean.
- [ ] Step 11.3: Confirm no
console.logand no leaked api-key strings
grep -RIn "console\." lib/factory/crawler-client.ts lib/factory/index-manager.ts || echo "no console calls"
grep -RIn "api[_-]key\|apiKey" lib/factory/crawler-client.ts | grep -v "import\|: string\|//\|/\*\|param" || echo "no leaked api key references in logs"
Expected: both lines emit "no ..." sentinel (or only show legitimate parameter declarations).
- [ ] Step 11.4: Mark Spec 06 done in
Status.md
In the vault Projects/Crawler-Factory/Status.md, change:
- | Cluster 6 (Crawler Client + Index Manager) | ⏸ | TS port + idempotent index lifecycle |
+ | Cluster 6 (Crawler Client + Index Manager) | ✅ | TS port + idempotent index lifecycle |
Acceptance criteria: Spec 06 done means:
- ✅
lib/factory/crawler-client.tsexportsCrawlerClientclass with:createCrawler,patchConfig,crawlUrls,startReindex,getStatus,getStats. - ✅ Auth header is
Basic base64(userId:apiKey): built once at construction, never rebuilt per request. - ✅ Base URL is
https://crawler.algolia.com/api/1(constant, not env-driven). - ✅ All crawler API responses pass through zod parse before being returned to callers.
- ✅ 4xx/5xx HTTP errors throw with sanitized message: error message MUST NOT contain the api key, the userId, or the full Authorization header.
- ✅
lib/factory/index-manager.tsexportsIndexManagerclass with:ensureIndex(contentDomain),ensureFactoryMetaIndices(). - ✅
ensureIndexis idempotent: cold start → create + neuralPending; existing+matching → no-op; existing+drift → update. - ✅ Drift comparison: order-sensitive on
searchableAttributes/customRanking; set-equality onattributesForFaceting. - ✅ Cold-start neuralSearch is NOT enabled;
neuralPending: trueis returned and logged. - ✅
ensureFactoryMetaIndicesconfiguresalgoliacentral_factory_sessions(filterOnly(record_type), filterOnly(parent_id)) andalgoliacentral_factory_blueprints(filterOnly(content_domain), filterOnly(status)). - ✅ All new tests pass (~16+ tests across crawler-client + index-manager).
- ✅ Full project test suite still GREEN.
- ✅
tsc --noEmitclean. - ✅ Per CodingSOPs: every module has logger, every public method has docstring, every fallible call has try/catch, every boundary uses zod.
Out of scope (handled by other specs)
/api/factory/create-crawlerand the rest of the factory endpoints → Spec 08 (consumes both classes built here)./api/factory/test-real(test crawler reuse + sample-URL crawls) → Spec 09 (consumesCrawlerClient)./api/factory/crawl-progressSSE endpoint that pollsgetStats→ Spec 11 (frontend) wires Spec 09's polling endpoint.- neuralSearch promotion (flipping
neuralPending: trueonce Insights events accrue) → Spec 13 (smoke + post-deploy promotion job). - Tenant-prefixed indices (
{tenant}_marketinginstead ofalgoliacentral_marketing) for multi-brand customers per §16k Decision 3: v1 sticks with the canonical DSS-derived names; tenant prefixing is a v2 enhancement deliberately deferred (§6 v2). - Real Algolia round-trip integration tests for both classes → Spec 13 (gated by env vars).
- Reindex-in-progress safety (
patch_config_safefrom the Python reference) → Spec 09 will layer this on top ofgetStatus({withConfig: true})+patchConfig. The TSCrawlerClientintentionally exposes the primitive only.