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.
$ 설치
git clone https://github.com/t1mmen/srtd /tmp/srtd && cp -r /tmp/srtd/.claude/skills/srtd-dev ~/.claude/skills/srtd// tip: Run this command in your terminal to install the skill
SKILL.md
name: srtd-dev description: 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.
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
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
// 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
// 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
// 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
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
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
// 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:
.option('-x, --example', 'Description')
- Pass to orchestrator method:
const result = await orchestrator.apply({ force, example: options.example });
- Handle in orchestrator:
async apply(options: ApplyOptions & { example?: boolean }) {
if (options.example) { /* ... */ }
}
- Add test:
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:
type OrchestratorEvents = {
newEvent: [payload: NewEventPayload];
// ... existing events
};
- Emit from appropriate location:
this.emit('newEvent', payload);
- Listen in command:
orchestrator.on('newEvent', (payload) => {
// Update UI
});
Error Handling Patterns
Service Layer
// 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
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
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
npm run typecheck && npm run lint && npm test
All three must pass. CI runs on Node 20.x and 22.x with PostgreSQL 15.
