Agent skill
srtd-dev
Expert knowledge for developing the SRTD codebase itself. Use when implementing features, fixing bugs, understanding architecture, or writing tests for SRTD internals. NOT for end users of srtd CLI.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/srtd-dev-t1mmen-srtd
SKILL.md
SRTD Development Skill
Expert guidance for working with the SRTD codebase - a CLI tool for live-reloading SQL templates into Supabase local databases.
Quick Reference
Key Commands
bash
npm test # Run all tests
npx vitest run -t "pattern" # Run specific test
npm run typecheck # Type check
npm run lint # Biome lint + fix
npm start -- watch # Run watch command
npm run supabase:start # Start test database
Key Files by Task
| Task | Primary Files |
|---|---|
| Add CLI option | src/commands/{command}.ts, src/cli.ts |
| Modify template processing | src/services/Orchestrator.ts |
| Change state tracking | src/services/StateService.ts |
| Fix database issues | src/services/DatabaseService.ts |
| Modify migration output | src/services/MigrationBuilder.ts |
| Change file watching | src/services/FileSystemService.ts |
| Update config | src/utils/config.ts, src/types.ts |
Architecture Mental Model
Unidirectional flow - data flows one direction through the system:
File Change → FileSystemService → Orchestrator → StateService
↓
DatabaseService / MigrationBuilder
↓
StateService (update) → Event Emission
Service Boundaries (Critical)
FileSystemService owns:
- Template discovery (glob matching)
- File watching (Chokidar, 100ms debounce)
- File I/O (read, write, rename)
- Hash computation (MD5)
StateService owns:
- All state mutations (single source of truth)
- Build log persistence (
.buildlog.json,.buildlog.local.json) - State machine transitions (UNSEEN → CHANGED → APPLIED/BUILT → SYNCED)
- Hash comparison for change detection
DatabaseService owns:
- Connection pooling (pg.Pool, max 10 connections)
- Retry logic (3 attempts, exponential backoff)
- Error categorization (CONNECTION_ERROR, SYNTAX_ERROR, etc.)
- Transaction management (BEGIN/COMMIT/ROLLBACK)
- Advisory locks per template
MigrationBuilder owns:
- Timestamp generation (increments from buildLog.lastTimestamp)
- Migration file formatting (banner, footer, transaction wrap)
- Bundle mode (multiple templates → single file)
Orchestrator owns:
- Service coordination (does NOT own state)
- Queue management (processQueue, pendingRecheck)
- Event emission (templateChanged, templateApplied, templateError)
- Command execution (apply, build, watch)
Key Design Decisions
- Dual build logs:
.buildlog.json(what was built, commit) +.buildlog.local.json(what was applied, gitignore) - Hash-based change detection:
currentHash !== lastAppliedHash && currentHash !== lastBuiltHash - EventEmitter pattern: Loose coupling between services
- Disposable pattern:
await usingfor automatic cleanup - Queue-based processing: FIFO with recheck for modified templates
Debugging Workflows
Template Not Processing
typescript
// Check 1: Is template being found?
// FileSystemService.findTemplates() uses glob pattern from config.filter
// Check 2: Is hash comparison returning false?
// StateService.hasTemplateChanged() compares against BOTH build logs
// Check 3: Is it a WIP template?
// isWipTemplate() checks for config.wipIndicator suffix (.wip.sql)
// Debugging: Add to Orchestrator.processTemplate():
console.log({
path,
hash: currentHash,
state: this.stateService.getTemplateStatus(path)
});
Database Connection Errors
typescript
// DatabaseService categorizes errors via DatabaseErrorType:
// - CONNECTION_ERROR: ECONNREFUSED, ENOTFOUND, ECONNRESET
// - POOL_EXHAUSTED: "pool is exhausted", "too many clients"
// - TIMEOUT_ERROR: ETIMEOUT or timeout in message
// Check pool status:
console.log({
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount
});
State Machine Issues
typescript
// Valid transitions in StateService:
// UNSEEN → CHANGED
// CHANGED → APPLIED, BUILT, ERROR
// APPLIED → CHANGED, SYNCED
// BUILT → CHANGED, SYNCED
// SYNCED → CHANGED
// ERROR → CHANGED
// Check current state:
const info = stateService.templateStates.get(absolutePath);
console.log({ state: info?.state, lastAppliedHash, lastBuiltHash });
Testing Patterns
Command Tests
typescript
import { setupCommandTestSpies, createMockUiModule } from '../helpers/testUtils.js';
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules(); // Critical: reload modules
spies = setupCommandTestSpies();
});
afterEach(() => spies.cleanup());
it('handles success', async () => {
const { buildCommand } = await import('../commands/build.js');
mockOrchestrator.build.mockResolvedValue({ built: ['file.sql'], errors: [] });
await buildCommand.parseAsync(['node', 'test']);
spies.assertNoStderr(); // Catch Commander parse errors
expect(spies.exitSpy).toHaveBeenCalledWith(0);
});
Service Tests with TestResource
typescript
import { createTestResource } from '../helpers/index.js';
it('applies template to database', async () => {
using resources = await createTestResource({ prefix: 'apply' });
await resources.setup();
// Create template with unique function name
const templatePath = await resources.createTemplateWithFunc('test', '_v1');
// Execute within transaction for isolation
const result = await resources.withTransaction(async (client) => {
// ... test logic
return client.query('SELECT ...');
});
// Verify function exists
expect(await resources.verifyFunctionExists()).toBe(true);
// Auto-cleanup via Symbol.asyncDispose
});
Mock Patterns
typescript
// Mock Orchestrator (most common)
vi.mock('../services/Orchestrator.js', () => ({
Orchestrator: {
create: vi.fn().mockResolvedValue({
apply: vi.fn().mockResolvedValue({ applied: [], errors: [], skipped: [] }),
build: vi.fn().mockResolvedValue({ built: [], errors: [], skipped: [] }),
watch: vi.fn().mockResolvedValue(undefined),
[Symbol.asyncDispose]: vi.fn(),
}),
},
}));
// Mock config
vi.mock('../utils/config.js', () => ({
getConfig: vi.fn().mockResolvedValue({
templateDir: '/tmp/templates',
migrationDir: '/tmp/migrations',
// ... other config
}),
}));
Adding New Features
New CLI Option
- Add option to command in
src/commands/{command}.ts:
typescript
.option('-x, --example', 'Description')
- Pass to orchestrator method:
typescript
const result = await orchestrator.apply({ force, example: options.example });
- Handle in orchestrator:
typescript
async apply(options: ApplyOptions & { example?: boolean }) {
if (options.example) { /* ... */ }
}
- Add test:
typescript
it('respects --example flag', async () => {
await command.parseAsync(['node', 'test', '--example']);
expect(mockOrchestrator.apply).toHaveBeenCalledWith(
expect.objectContaining({ example: true })
);
});
New Service Method
- Define interface in
src/types.ts - Implement in service class
- Expose via Orchestrator if needed
- Add unit test for service
- Add integration test for full flow
New Event Type
- Define event type in Orchestrator:
typescript
type OrchestratorEvents = {
newEvent: [payload: NewEventPayload];
// ... existing events
};
- Emit from appropriate location:
typescript
this.emit('newEvent', payload);
- Listen in command:
typescript
orchestrator.on('newEvent', (payload) => {
// Update UI
});
Error Handling Patterns
Service Layer
typescript
// Categorize and wrap errors
try {
await pool.query(sql);
} catch (error) {
const dbError = this.categorizeError(error);
this.emit('sql:error', { error: dbError });
throw dbError;
}
Command Layer
typescript
try {
const result = await orchestrator.apply();
process.exit(result.errors.length > 0 ? 1 : 0);
} catch (error) {
console.log(chalk.red(getErrorMessage(error)));
process.exit(1);
}
Interactive Commands
typescript
try {
const answer = await select({ /* ... */ });
} catch (error) {
if (isPromptExit(error)) {
process.exit(0); // Ctrl+C is clean exit
}
throw error;
}
Common Pitfalls
- Forgetting vi.resetModules() - Command tests fail silently without it
- Not capturing console.error - Commander writes parse errors to stderr
- Direct state mutation - Always use StateService methods, never modify directly
- Missing transaction cleanup - Use
usingpattern or explicit dispose - Testing with shared state - Use TestResource for isolation
- Forgetting Symbol.asyncDispose - Orchestrator requires async disposal
Validation Before Commit
bash
npm run typecheck && npm run lint && npm test
All three must pass. CI runs on Node 20.x and 22.x with PostgreSQL 15.
Didn't find tool you were looking for?