| 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
npx vitest run -t "pattern"
npm run typecheck
npm run lint
npm start -- watch
npm run supabase:start
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 using for automatic cleanup
- Queue-based processing: FIFO with recheck for modified templates
Debugging Workflows
Template Not Processing
console.log({
path,
hash: currentHash,
state: this.stateService.getTemplateStatus(path)
});
Database Connection Errors
console.log({
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount
});
State Machine Issues
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();
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();
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();
const templatePath = await resources.createTemplateWithFunc('test', '_v1');
const result = await resources.withTransaction(async (client) => {
return client.query('SELECT ...');
});
expect(await resources.verifyFunctionExists()).toBe(true);
});
Mock Patterns
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(),
}),
},
}));
vi.mock('../utils/config.js', () => ({
getConfig: vi.fn().mockResolvedValue({
templateDir: '/tmp/templates',
migrationDir: '/tmp/migrations',
}),
}));
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];
};
- Emit from appropriate location:
this.emit('newEvent', payload);
- Listen in command:
orchestrator.on('newEvent', (payload) => {
});
Error Handling Patterns
Service Layer
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);
}
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
using pattern 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.