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.
Expert guidance for working with the SRTD codebase - a CLI tool for live-reloading SQL templates into Supabase local databases.
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
| 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 |
Unidirectional flow - data flows one direction through the system:
File Change → FileSystemService → Orchestrator → StateService
↓
DatabaseService / MigrationBuilder
↓
StateService (update) → Event Emission
FileSystemService owns:
StateService owns:
.buildlog.json, .buildlog.local.json)DatabaseService owns:
MigrationBuilder owns:
Orchestrator owns:
.buildlog.json (what was built, commit) + .buildlog.local.json (what was applied, gitignore)currentHash !== lastAppliedHash && currentHash !== lastBuiltHashawait using for automatic cleanup// 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)
});
// 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
});
// 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 });
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);
});
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 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
}),
}));
src/commands/{command}.ts:.option('-x, --example', 'Description')
const result = await orchestrator.apply({ force, example: options.example });
async apply(options: ApplyOptions & { example?: boolean }) {
if (options.example) { /* ... */ }
}
it('respects --example flag', async () => {
await command.parseAsync(['node', 'test', '--example']);
expect(mockOrchestrator.apply).toHaveBeenCalledWith(
expect.objectContaining({ example: true })
);
});
src/types.tstype OrchestratorEvents = {
newEvent: [payload: NewEventPayload];
// ... existing events
};
this.emit('newEvent', payload);
orchestrator.on('newEvent', (payload) => {
// Update UI
});
// Categorize and wrap errors
try {
await pool.query(sql);
} catch (error) {
const dbError = this.categorizeError(error);
this.emit('sql:error', { error: dbError });
throw dbError;
}
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);
}
try {
const answer = await select({ /* ... */ });
} catch (error) {
if (isPromptExit(error)) {
process.exit(0); // Ctrl+C is clean exit
}
throw error;
}
using pattern or explicit disposenpm run typecheck && npm run lint && npm test
All three must pass. CI runs on Node 20.x and 22.x with PostgreSQL 15.