Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ temp/

# Config files
.mock-config.json
docs/
docs/

# Mock data persistence
.mock-data.json
.mock-data.json.tmp
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,22 @@ TypeScript is strict (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexe
- `x-mock-status: <code>` — forces the response HTTP status code (handled by `src/middlewares/statusOverride.ts`; ignored in `strict` mode by not mounting the middleware)

**System routes** (not matched by dynamic handler):
- `GET /health` — server status + cache stats
- `GET /api-docs` — Swagger UI (spec auto-regenerated on hot-reload file changes)
- `GET /health` — server status + cache stats + list of available type names (`types: string[]`)
- `GET /api-docs` — Swagger UI (spec auto-regenerated on hot-reload file changes; includes type-selector dropdown for selective rebuild)
- `POST /mock-reset` — clear all mock data and re-seed
- `POST /mock-reset/:typeName` — regenerate mock data for a single type only; 404 if type unknown

**JSON persistence** (`persistData?: string | false` in `ServerConfig`):
- Opt-in via `--persist-data [path]` CLI or wizard advanced options (default path: `.mock-data.json`)
- Startup: `seedAllPools` runs first, then if file exists it loads pools from file (corrupt files are left untouched); then saves to ensure file matches `typesDir`
- After POST/PUT/PATCH/DELETE: `saveMockData` is called from `router.ts` via `maybePersist(config)`
- After `/mock-reset` or hot-reload: file is overwritten with fresh data
- `POST /mock-reset/:typeName`: only the target type is regenerated and saved
- File format: `{ TypeName: [...items] }` — keyed by TypeScript interface name, not route name
- Atomic write: write to `.mock-data.json.tmp` then `rename` (no corruption on interruption)
- Empty array `[]` is a valid persisted state (all items deleted) — not regenerated on reload
- Unknown keys in the file are silently skipped (debug log emitted)
- Module: `src/utils/dataPersistence.ts` — `saveMockData(store, typesDir, filePath)` and `loadMockData(store, typesDir, filePath)`
- `MockDataStore.getLivePool(typeName, filePath)` — the authoritative merge of pool + writeStore − deletedIds; used by both the router (GET collection) and the persistence module

**Key types** (`src/types/config.ts`): `ServerConfig`, `MockMode`, `RouteTypeMapping`, `InterfaceMetadata`, `ParsedSchema`, `MockGenerationOptions`
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Options:
-p, --port <number> Server port (default: 8080)
-l, --latency <range> Latency simulation "min-max" (e.g., 500-2000)
--mock-mode <strict|dev> Mock mode (default: dev)
--persist-data [path] Persist mock data to JSON file (default: .mock-data.json)
--no-hot-reload Disable auto-reload on changes
--no-cache Disable schema caching
-v, --verbose Enable verbose logging
Expand Down Expand Up @@ -306,6 +307,77 @@ export interface Product {

---

## JSON Persistence

By default, mock data is purely in-memory and resets on every server restart. Enable persistence to keep data between sessions or share a stable dataset across restarts.

### Activation

**CLI:**
```bash
npx ts-mock-proxy --types-dir ./types --persist-data
# Uses default path: .mock-data.json

npx ts-mock-proxy --types-dir ./types --persist-data ./data/mocks.json
# Custom path
```

**Wizard:** enable in the advanced options section.

**Config file** (`.mock-config.json`):
```json
{ "persistData": ".mock-data.json" }
```

### File Format

The file is a flat JSON object keyed by TypeScript interface name:

```json
{
"User": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"Post": [
{ "id": 1, "title": "Hello World", "authorId": 1 }
]
}
```

You can edit this file manually while the server is stopped. Changes are loaded on the next startup.

> **Warning:** if the server is running and a mutation (POST/PUT/PATCH/DELETE) occurs between your manual edit and the next restart, the automatic save will overwrite your edits. Stop the server before editing the file.

### Behaviour

| Situation | Result |
|---|---|
| First launch, no file | File created with generated data |
| Restart, file present | Pools replaced by file content |
| New type added to `typesDir` | Generated and added to file at startup |
| POST / PUT / PATCH / DELETE | File updated atomically after every mutation |
| `POST /mock-reset` | File overwritten with freshly generated data |
| `POST /mock-reset/User` | Only `User` regenerated and saved |
| `[]` in file | Preserved as-is — not regenerated (intentional empty state) |
| Invalid JSON in file | Warning emitted, server starts normally, file not overwritten |
| Hot-reload (type file changed) | Affected types regenerated and saved; others untouched |

Writes are atomic: the server writes to `.mock-data.json.tmp` first then renames it, so an interrupted write never corrupts the existing file.

### Selective Rebuild

Regenerate one type without affecting others:

```bash
POST /mock-reset/User
# → {"message": "Mock data regenerated for type \"User\"", "type": "User", "count": 10}
```

The Swagger UI also exposes a type selector dropdown ("Select type… → Rebuild selected") next to the existing "Rebuild Data" (all) button.

---

## Mock Modes

The server supports two modes controlled by `mockMode`:
Expand Down
27 changes: 27 additions & 0 deletions src/cli/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export async function runWizard(): Promise<ServerConfig> {
let verbose = savedConfig?.verbose ?? false;
let writeMethods: ServerConfig['writeMethods'] = savedConfig?.writeMethods;
let mockMode: MockMode = savedConfig?.mockMode ?? 'dev';
let persistData: string | false = savedConfig?.persistData ?? false;

if (advancedAnswer.showAdvanced) {
const advOptions = await inquirer.prompt([
Expand Down Expand Up @@ -214,6 +215,30 @@ export async function runWizard(): Promise<ServerConfig> {
latency = undefined;
}

// Persistence configuration
const persistAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'enablePersist',
message: 'Persist mock data to JSON file?',
default: !!persistData,
},
]);

if (persistAnswer.enablePersist) {
const persistPathAnswer = await inquirer.prompt([
{
type: 'input',
name: 'persistPath',
message: 'Path to persist file?',
default: typeof persistData === 'string' ? persistData : '.mock-data.json',
},
]);
persistData = persistPathAnswer.persistPath as string;
} else {
persistData = false;
}

// Write methods configuration
const writeMethodsAnswer = await inquirer.prompt([
{
Expand Down Expand Up @@ -259,6 +284,7 @@ export async function runWizard(): Promise<ServerConfig> {
verbose,
writeMethods,
mockMode,
persistData: persistData || undefined,
};

displayConfigSummary(config);
Expand Down Expand Up @@ -312,6 +338,7 @@ function displayConfigSummary(config: ServerConfig): void {
console.log(` ${chalk.cyan('Hot-reload:')} ${config.hotReload ? 'enabled' : 'disabled'}`);
console.log(` ${chalk.cyan('Cache:')} ${config.cache ? 'enabled' : 'disabled'}`);
console.log(` ${chalk.cyan('Verbose:')} ${config.verbose ? 'enabled' : 'disabled'}`);
console.log(` ${chalk.cyan('Persist data:')} ${config.persistData ? config.persistData : 'disabled'}`);

const wm = config.writeMethods;
if (wm) {
Expand Down
33 changes: 33 additions & 0 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ParsedSchema } from '../types/config';
import { logger } from '../utils/logger';
import { extractMockId } from '../utils/mockId';

/**
* In-memory cache for parsed TypeScript schemas
Expand Down Expand Up @@ -223,6 +224,13 @@ export class MockDataStore {
}
}

invalidateType(typeName: string, filePath: string): void {
const k = this.key(typeName, filePath);
this.pools.delete(k);
this.writeStore.delete(k);
this.deletedIds.delete(k);
}

clear(): { pools: number } {
const pools = this.pools.size;
this.pools.clear();
Expand All @@ -235,6 +243,31 @@ export class MockDataStore {
getStats(): { pools: number } {
return { pools: this.pools.size };
}

/**
* Returns the live pool for a type: write-store entries merged with the seeded pool,
* excluding deleted IDs. Write-store entries take precedence over pool items.
*/
getLivePool(typeName: string, filePath: string): Record<string, unknown>[] {
const pool = this.pools.get(this.key(typeName, filePath))?.data ?? [];
const deletedIds = this.deletedIds.get(this.key(typeName, filePath)) ?? new Set<string>();
const writeEntries = this.writeStore.get(this.key(typeName, filePath)) ?? new Map<string, Record<string, unknown>>();

const fromPool = pool.filter((item) => {
const id = extractMockId(item);
if (id === undefined) return true;
if (deletedIds.has(id)) return false;
if (writeEntries.has(id)) return false;
return true;
});

const fromWriteStore = Array.from(writeEntries.values()).filter((item) => {
const id = extractMockId(item);
return id === undefined || !deletedIds.has(id);
});

return [...fromWriteStore, ...fromPool];
}
}

export const mockDataStore = new MockDataStore();
54 changes: 14 additions & 40 deletions src/core/router.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as path from 'path';
import { Request, Response } from 'express';
import { ServerConfig, ApiErrorResponse } from '../types/config';
import { findTypeForUrl } from '../utils/typeMapping';
import { parseUrlSegments, isIdSegment } from '../utils/pluralize';
import { generateMockFromInterface, generateMockArray } from './parser';
import { mockDataStore } from './cache';
import { logger } from '../utils/logger';
import { saveMockData } from '../utils/dataPersistence';
import { extractMockId } from '../utils/mockId';
import {
parseQueryParams,
validateSortFields,
Expand All @@ -28,16 +31,6 @@ function extractIdFromUrl(url: string): string | undefined {
return undefined;
}

/** Returns the value of the first recognised ID field (id, uuid, _id) in a mock object. */
function extractMockId(obj: Record<string, unknown>): string | undefined {
for (const field of ['id', 'uuid', '_id']) {
if (obj[field] !== undefined) {
return String(obj[field]);
}
}
return undefined;
}

/** Returns the name of the first recognised ID field present in the object, if any. */
function findIdField(obj: Record<string, unknown>): string | undefined {
for (const field of ['id', 'uuid', '_id']) {
Expand Down Expand Up @@ -90,32 +83,10 @@ function updatePoolEntry(
mockDataStore.setPool(typeName, filePath, pool);
}

/**
* Builds the "live pool" for a collection endpoint by merging the seeded pool
* with write-store entries, excluding deleted items and replacing overridden ones.
*/
function buildLivePool(
typeName: string,
filePath: string,
pool: Record<string, unknown>[]
): Record<string, unknown>[] {
const deletedIds = mockDataStore.getDeletedIds(typeName, filePath);
const writeEntries = mockDataStore.getAllWriteEntries(typeName, filePath);

const fromPool = pool.filter((item) => {
const id = extractMockId(item);
if (id === undefined) return true;
if (deletedIds.has(id)) return false;
if (writeEntries.has(id)) return false; // write-store version takes precedence
return true;
});

const fromWriteStore = Array.from(writeEntries.values()).filter((item) => {
const id = extractMockId(item);
return id === undefined || !deletedIds.has(id);
});

return [...fromWriteStore, ...fromPool];
/** Saves mock data to the persist file if persistData is configured. */
function maybePersist(config: ServerConfig): void {
if (!config.persistData) return;
saveMockData(mockDataStore, config.typesDir, path.resolve(config.persistData));
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -148,13 +119,12 @@ async function handleGet(
}

// Seed pool on first request
let pool = mockDataStore.getPool(mapping.typeName, filePath);
if (!pool) {
pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE });
if (!mockDataStore.getPool(mapping.typeName, filePath)) {
const pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE });
mockDataStore.setPool(mapping.typeName, filePath, pool);
}

const livePool = buildLivePool(mapping.typeName, filePath, pool);
const livePool = mockDataStore.getLivePool(mapping.typeName, filePath);

if (parsed.sort.length > 0 && livePool.length > 0) {
const firstItem = livePool[0];
Expand Down Expand Up @@ -252,6 +222,7 @@ async function handlePost(
const basePath = req.path.replace(/\/$/, '');
const location = id !== undefined ? `${basePath}/${id}` : basePath;

maybePersist(config);
res.status(forcedStatus || 201).set('Location', location).json(merged);
}

Expand Down Expand Up @@ -305,6 +276,7 @@ async function handlePut(
updatePoolEntry(mapping.typeName, filePath, id, merged);
}

maybePersist(config);
res.status(forcedStatus || 200).json(merged);
}

Expand Down Expand Up @@ -373,6 +345,7 @@ async function handlePatch(
updatePoolEntry(mapping.typeName, filePath, id, merged);
}

maybePersist(config);
res.status(forcedStatus || 200).json(merged);
}

Expand Down Expand Up @@ -415,6 +388,7 @@ async function handleDelete(
}
}

maybePersist(config);
res.status(forcedStatus || 204).send();
}

Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async function main() {
.option('--no-cache', 'Disable schema caching')
.option('-v, --verbose', 'Enable verbose logging', false)
.option('--mock-mode <strict|dev>', 'Mock mode: "dev" enables all mock features (default), "strict" disables them')
.option('--persist-data [path]', 'Persist mock data to JSON file (default path: .mock-data.json)')
.option('--interactive', 'Force interactive mode')
.action(async (options) => {
// If --interactive flag is set, run wizard instead
Expand All @@ -77,6 +78,14 @@ async function main() {
// Resolve mockMode: CLI > ENV > default
const mockMode = resolveMockMode(options.mockMode);

// Resolve persistData: --persist-data with no arg → default path; with path → use it; absent → disabled
let persistData: string | undefined;
if (options.persistData !== undefined) {
persistData = typeof options.persistData === 'string' && options.persistData !== ''
? options.persistData
: '.mock-data.json';
}

// Build the configuration
const config: ServerConfig = {
typesDir,
Expand All @@ -86,6 +95,7 @@ async function main() {
cache: options.cache !== false,
verbose: options.verbose,
mockMode,
persistData,
};

// Configure the global cache
Expand Down
Loading
Loading