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

  1. Dual build logs: .buildlog.json (what was built, commit) + .buildlog.local.json (what was applied, gitignore)
  2. Hash-based change detection: currentHash !== lastAppliedHash && currentHash !== lastBuiltHash
  3. EventEmitter pattern: Loose coupling between services
  4. Disposable pattern: await using for automatic cleanup
  5. 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

  1. Add option to command in src/commands/{command}.ts:
typescript
.option('-x, --example', 'Description')
  1. Pass to orchestrator method:
typescript
const result = await orchestrator.apply({ force, example: options.example });
  1. Handle in orchestrator:
typescript
async apply(options: ApplyOptions & { example?: boolean }) {
  if (options.example) { /* ... */ }
}
  1. 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

  1. Define interface in src/types.ts
  2. Implement in service class
  3. Expose via Orchestrator if needed
  4. Add unit test for service
  5. Add integration test for full flow

New Event Type

  1. Define event type in Orchestrator:
typescript
type OrchestratorEvents = {
  newEvent: [payload: NewEventPayload];
  // ... existing events
};
  1. Emit from appropriate location:
typescript
this.emit('newEvent', payload);
  1. 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

  1. Forgetting vi.resetModules() - Command tests fail silently without it
  2. Not capturing console.error - Commander writes parse errors to stderr
  3. Direct state mutation - Always use StateService methods, never modify directly
  4. Missing transaction cleanup - Use using pattern or explicit dispose
  5. Testing with shared state - Use TestResource for isolation
  6. 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?

Be as detailed as possible for better results