From 36e082ac8b8fed35bed771a81f03109c115cd1f6 Mon Sep 17 00:00:00 2001 From: pppwtk <159458725+xircons@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:02:50 +0700 Subject: [PATCH] feat(cli): enhance watch mode and dynamic routing - Resolve watched DB file path to absolute path to prevent watcher resolution issues. - Update Chokidar configuration to use `awaitWriteFinish` and listen to both `change` and `add` events (for atomic save compatibility). - Refactor routes to use dynamic Express parameters (`/:resource` and `/:resource/:id`), allowing the mock server to adapt immediately to schema changes (added/removed keys) without restart. - Introduce `JsonStore.isSelfWrite()` to prevent reloading the database file on self-induced saves. - Add test coverage for watch reloading and self-write filters. --- src/__tests__/integration.test.ts | 87 +++++++++++ src/__tests__/selfWrite.test.ts | 29 ++++ src/__tests__/watch.test.ts | 132 ++++++++++++++++ src/index.ts | 27 +++- src/server/app.ts | 4 + src/server/routes/dynamicRouter.ts | 236 ++++++++++++++++------------- src/store/jsonStore.ts | 7 + 7 files changed, 408 insertions(+), 114 deletions(-) create mode 100644 src/__tests__/integration.test.ts create mode 100644 src/__tests__/selfWrite.test.ts create mode 100644 src/__tests__/watch.test.ts diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts new file mode 100644 index 0000000..c3cbe88 --- /dev/null +++ b/src/__tests__/integration.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Server } from 'http'; +import type { AddressInfo } from 'net'; +import fs from 'fs'; +import path from 'path'; +import { JsonStore } from '../store/jsonStore'; +import { createApp } from '../server/app'; + +// Exercises the real Express stack (param routes, JSON 404, store wiring) over HTTP, +// rather than poking router internals. +describe('Dynamic router integration (real HTTP)', () => { + const tempFile = path.resolve(__dirname, 'temp-integration-db.json'); + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + fs.writeFileSync( + tempFile, + JSON.stringify({ posts: [{ id: 1, title: 'Hello' }] }), + ); + await JsonStore.load(tempFile); + + const app = createApp({ delayMs: 0 }); + await new Promise((resolve) => { + server = app.listen(0, resolve); + }); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + }); + + it('lists a collection', async () => { + const res = await fetch(`${baseUrl}/posts`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([{ id: 1, title: 'Hello' }]); + }); + + it('gets an item by id', async () => { + const res = await fetch(`${baseUrl}/posts/1`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: 1, title: 'Hello' }); + }); + + it('returns JSON 404 for an unknown resource', async () => { + const res = await fetch(`${baseUrl}/widgets`); + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('Not found'); + }); + + it('returns JSON 404 for a known resource with a missing id', async () => { + const res = await fetch(`${baseUrl}/posts/999`); + expect(res.status).toBe(404); + expect(((await res.json()) as { error: string }).error).toContain('999'); + }); + + it('creates a record via POST and assigns the next id', async () => { + const res = await fetch(`${baseUrl}/posts`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'World' }), + }); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ id: 2, title: 'World' }); + }); + + it('serves a collection added to the store after startup (no restart)', async () => { + // Simulate a watch reload introducing a new top-level key. + fs.writeFileSync( + tempFile, + JSON.stringify({ + posts: [{ id: 1, title: 'Hello' }], + comments: [{ id: 1, body: 'Nice' }], + }), + ); + await JsonStore.load(tempFile, true); + + const res = await fetch(`${baseUrl}/comments`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([{ id: 1, body: 'Nice' }]); + }); +}); diff --git a/src/__tests__/selfWrite.test.ts b/src/__tests__/selfWrite.test.ts new file mode 100644 index 0000000..75fc738 --- /dev/null +++ b/src/__tests__/selfWrite.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { JsonStore } from '../store/jsonStore'; + +// Isolated in its own file so the JsonStore singleton starts with a clean +// (unset) self-write window. +describe('Self-write suppression', () => { + const tempFile = path.resolve(__dirname, 'temp-selfwrite-db.json'); + + beforeAll(() => { + fs.writeFileSync(tempFile, JSON.stringify({ posts: [{ id: 1 }] })); + }); + + afterAll(() => { + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + }); + + it('marks recent saves as self-caused so the watcher ignores them', async () => { + await JsonStore.load(tempFile); + expect(JsonStore.isSelfWrite()).toBe(false); + + JsonStore.getData().posts.push({ id: 2 }); + await JsonStore.save(); + + // Immediately after save(), a file event would be ours — must be suppressed. + expect(JsonStore.isSelfWrite()).toBe(true); + }); +}); diff --git a/src/__tests__/watch.test.ts b/src/__tests__/watch.test.ts new file mode 100644 index 0000000..c613422 --- /dev/null +++ b/src/__tests__/watch.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { JsonStore } from '../store/jsonStore'; +import { buildDynamicRouter } from '../server/routes/dynamicRouter'; + +// The tests interrogate Express router internals, which aren't fully typed. +function getResourceGetHandler(router: any): (req: any, res: any, next: any) => Promise { + const layer = router.stack.find( + (l: any) => l.route && l.route.path === '/:resource' && l.route.methods.get, + ); + expect(layer).toBeDefined(); + return layer.route.stack[0].handle; +} + +describe('Watch Mode and Dynamic Routing', () => { + const tempFile = path.resolve(__dirname, 'temp-watch-db.json'); + + beforeAll(() => { + fs.writeFileSync(tempFile, JSON.stringify({ + posts: [{ id: 1, title: 'Original Title' }] + })); + }); + + afterAll(() => { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + }); + + it('should load initial data and dynamically serve it', async () => { + await JsonStore.load(tempFile); + expect(JsonStore.getData().posts).toEqual([{ id: 1, title: 'Original Title' }]); + + const router = buildDynamicRouter(); + const handler = getResourceGetHandler(router); + + let jsonResponse: any = null; + const req = { + params: { resource: 'posts' }, + query: {} + } as any; + const res = { + setHeader: () => {}, + json: (data: any) => { + jsonResponse = data; + } + } as any; + + await handler(req, res, () => {}); + + expect(jsonResponse).toEqual([{ id: 1, title: 'Original Title' }]); + }); + + it('should reflect updates to existing resources dynamically without recreating router', async () => { + fs.writeFileSync(tempFile, JSON.stringify({ + posts: [{ id: 1, title: 'Updated Title' }] + })); + + await JsonStore.load(tempFile, true); + + const router = buildDynamicRouter(); + const handler = getResourceGetHandler(router); + + let jsonResponse: any = null; + const req = { + params: { resource: 'posts' }, + query: {} + } as any; + const res = { + setHeader: () => {}, + json: (data: any) => { + jsonResponse = data; + } + } as any; + + await handler(req, res, () => {}); + expect(jsonResponse).toEqual([{ id: 1, title: 'Updated Title' }]); + }); + + it('should dynamically serve newly added resources without recreating router', async () => { + fs.writeFileSync(tempFile, JSON.stringify({ + posts: [{ id: 1, title: 'Updated Title' }], + comments: [{ id: 1, body: 'New Comment' }] + })); + + await JsonStore.load(tempFile, true); + + const router = buildDynamicRouter(); + const handler = getResourceGetHandler(router); + + let postsResponse: any = null; + const reqPosts = { params: { resource: 'posts' }, query: {} } as any; + const resPosts = { + setHeader: () => {}, + json: (data: any) => { postsResponse = data; } + } as any; + await handler(reqPosts, resPosts, () => {}); + expect(postsResponse).toEqual([{ id: 1, title: 'Updated Title' }]); + + let commentsResponse: any = null; + const reqComments = { params: { resource: 'comments' }, query: {} } as any; + const resComments = { + setHeader: () => {}, + json: (data: any) => { commentsResponse = data; } + } as any; + + let nextCalled = false; + await handler(reqComments, resComments, () => { nextCalled = true; }); + + expect(nextCalled).toBe(false); + expect(commentsResponse).toEqual([{ id: 1, body: 'New Comment' }]); + }); + + it('should fall through to next() for non-existent resources', async () => { + const router = buildDynamicRouter(); + const handler = getResourceGetHandler(router); + + const req = { params: { resource: 'nonexistent' }, query: {} } as any; + const res = { + setHeader: () => {}, + json: () => { throw new Error('Should not call json()'); } + } as any; + + let nextCalled = false; + await handler(req, res, () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 82aeddf..10c876b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import * as chokidar from "chokidar"; +import path from "path"; import pc from "picocolors"; import { Command } from "commander"; import { JsonStore } from "./store/jsonStore"; @@ -33,6 +34,7 @@ const program = new Command(); let watcher: chokidar.FSWatcher | null = null; function startFileWatcher(filePath: string): void { + const absolutePath = path.resolve(filePath); let reloadTimeout: ReturnType | null = null; if (watcher) { @@ -41,20 +43,33 @@ function startFileWatcher(filePath: string): void { } const reload = async (): Promise => { + // Skip events caused by our own save(). + if (JsonStore.isSelfWrite()) { + return; + } try { - await JsonStore.load(filePath, true); - console.log(`\n ${pc.dim('│')} ${pc.green('✓')} ${pc.dim('Hot reloaded data from')} ${pc.white(filePath)}`); + await JsonStore.load(absolutePath, true); + console.log(`\n ${pc.dim('│')} ${pc.green('✓')} ${pc.dim('Hot reloaded data from')} ${pc.white(absolutePath)}`); } catch (err: any) { printError('WATCH_RELOAD_FAILED', err.message); } }; try { - watcher = chokidar.watch(filePath, { persistent: true, ignoreInitial: true }); + watcher = chokidar.watch(absolutePath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50 + } + }); watcher - .on("change", () => { - if (reloadTimeout) clearTimeout(reloadTimeout); - reloadTimeout = setTimeout(() => void reload(), 100); + .on("all", (event) => { + if (event === "change" || event === "add") { + if (reloadTimeout) clearTimeout(reloadTimeout); + reloadTimeout = setTimeout(() => void reload(), 100); + } }) .on("error", (err: unknown) => { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/server/app.ts b/src/server/app.ts index b8ad0db..e0cd2ca 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -41,7 +41,11 @@ export function createApp(options: CreateAppOptions): Application { app.use(express.json()); app.use(requestLoggingMiddleware); app.use(delayMiddleware(options.delayMs)); + // Catch-all `/:resource` routes — mount any reserved endpoint above this line. app.use("/", buildDynamicRouter()); + app.use((req: Request, res: Response) => { + res.status(404).json({ error: `Not found: ${req.method} ${req.path}` }); + }); app.use(errorHandler); return app; } diff --git a/src/server/routes/dynamicRouter.ts b/src/server/routes/dynamicRouter.ts index 0579a68..0694896 100644 --- a/src/server/routes/dynamicRouter.ts +++ b/src/server/routes/dynamicRouter.ts @@ -210,25 +210,32 @@ function asyncHandler( export function buildDynamicRouter(): Router { const router = express.Router(); - const resources = Object.keys(JsonStore.getData()); - for (const resource of resources) { - router.get( - `/${resource}/:id`, - asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params; - const collection = JsonStore.getData()[resource]; - const item = findItemById(collection, id); - if (item === undefined) { - itemNotFound(res, resource, id); - return; - } - res.json(item); - }), - ); + router.get( + `/:resource/:id`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource, id } = req.params; + const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } + const item = findItemById(collection, id); + if (item === undefined) { + itemNotFound(res, resource, id); + return; + } + res.json(item); + }), + ); - router.get(`/${resource}`, (req: Request, res: Response) => { + router.get( + `/:resource`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource } = req.params; const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } const query = req.query as Record; let rows = filterCollection(collection, query); const total = rows.length; @@ -252,102 +259,115 @@ export function buildDynamicRouter(): Router { return; } res.json(rows); - }); + }), + ); - router.post( - `/${resource}`, - asyncHandler(async (req: Request, res: Response) => { - if (!isPlainObject(req.body)) { - badBody(res); - return; - } - const collection = JsonStore.getData()[resource]; - if (!validateBody(res, collection, req.body)) return; - const newId = nextIdForCollection(collection); - const rawBody = req.body as Record; - const rest: Record = { ...rawBody }; - delete rest["id"]; - const newItem: Record = { ...rest, id: newId }; - collection.push(newItem); - await JsonStore.save(); - res.status(201).json(newItem); - }), - ); + router.post( + `/:resource`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource } = req.params; + const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } + if (!isPlainObject(req.body)) { + badBody(res); + return; + } + if (!validateBody(res, collection, req.body)) return; + const newId = nextIdForCollection(collection); + const rawBody = req.body as Record; + const rest: Record = { ...rawBody }; + delete rest["id"]; + const newItem: Record = { ...rest, id: newId }; + collection.push(newItem); + await JsonStore.save(); + res.status(201).json(newItem); + }), + ); - router.put( - `/${resource}/:id`, - asyncHandler(async (req: Request, res: Response) => { - if (!isPlainObject(req.body)) { - badBody(res); - return; - } - const { id: idParam } = req.params; - const collection = JsonStore.getData()[resource]; - if (!validateBody(res, collection, req.body)) return; - const index = findIndexById(collection, idParam); - if (index === -1) { - itemNotFound(res, resource, idParam); - return; - } - const previous = collection[index]; - const preservedId = isRecordWithId(previous) ? previous.id : idParam; - const rawBody = req.body as Record; - const rest: Record = { ...rawBody }; - delete rest["id"]; - const updated: Record = { ...rest, id: preservedId }; - collection[index] = updated; - await JsonStore.save(); - res.json(updated); - }), - ); + router.put( + `/:resource/:id`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource, id: idParam } = req.params; + const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } + if (!isPlainObject(req.body)) { + badBody(res); + return; + } + if (!validateBody(res, collection, req.body)) return; + const index = findIndexById(collection, idParam); + if (index === -1) { + itemNotFound(res, resource, idParam); + return; + } + const previous = collection[index]; + const preservedId = isRecordWithId(previous) ? previous.id : idParam; + const rawBody = req.body as Record; + const rest: Record = { ...rawBody }; + delete rest["id"]; + const updated: Record = { ...rest, id: preservedId }; + collection[index] = updated; + await JsonStore.save(); + res.json(updated); + }), + ); - router.patch( - `/${resource}/:id`, - asyncHandler(async (req: Request, res: Response) => { - if (!isPlainObject(req.body)) { - badBody(res); - return; - } - const { id: idParam } = req.params; - const collection = JsonStore.getData()[resource]; - if (!validateBody(res, collection, req.body)) return; - const index = findIndexById(collection, idParam); - if (index === -1) { - itemNotFound(res, resource, idParam); - return; - } - const current = collection[index]; - if (!isPlainObject(current)) { - res.status(400).json({ error: "Existing item must be an object to PATCH." }); - return; - } - const preservedId = isRecordWithId(current) ? current.id : idParam; - const rawBody = req.body as Record; - const patch: Record = { ...rawBody }; - delete patch["id"]; - const updated: Record = { ...current, ...patch, id: preservedId }; - collection[index] = updated; - await JsonStore.save(); - res.json(updated); - }), - ); + router.patch( + `/:resource/:id`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource, id: idParam } = req.params; + const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } + if (!isPlainObject(req.body)) { + badBody(res); + return; + } + if (!validateBody(res, collection, req.body)) return; + const index = findIndexById(collection, idParam); + if (index === -1) { + itemNotFound(res, resource, idParam); + return; + } + const current = collection[index]; + if (!isPlainObject(current)) { + res.status(400).json({ error: "Existing item must be an object to PATCH." }); + return; + } + const preservedId = isRecordWithId(current) ? current.id : idParam; + const rawBody = req.body as Record; + const patch: Record = { ...rawBody }; + delete patch["id"]; + const updated: Record = { ...current, ...patch, id: preservedId }; + collection[index] = updated; + await JsonStore.save(); + res.json(updated); + }), + ); - router.delete( - `/${resource}/:id`, - asyncHandler(async (req: Request, res: Response) => { - const { id: idParam } = req.params; - const collection = JsonStore.getData()[resource]; - const index = findIndexById(collection, idParam); - if (index === -1) { - itemNotFound(res, resource, idParam); - return; - } - collection.splice(index, 1); - await JsonStore.save(); - res.status(204).send(); - }), - ); - } + router.delete( + `/:resource/:id`, + asyncHandler(async (req: Request, res: Response, next: NextFunction) => { + const { resource, id: idParam } = req.params; + const collection = JsonStore.getData()[resource]; + if (!collection) { + return next(); + } + const index = findIndexById(collection, idParam); + if (index === -1) { + itemNotFound(res, resource, idParam); + return; + } + collection.splice(index, 1); + await JsonStore.save(); + res.status(204).send(); + }), + ); return router; } diff --git a/src/store/jsonStore.ts b/src/store/jsonStore.ts index cf4320c..5af7a55 100644 --- a/src/store/jsonStore.ts +++ b/src/store/jsonStore.ts @@ -14,6 +14,12 @@ class JsonStoreImpl { private backingPath: string | null = null; /** Serializes concurrent save() calls so writes are not interleaved. */ private saveChain: Promise = Promise.resolve(); + /** Epoch ms until which file-change events are treated as self-caused and ignored by the watcher. */ + private selfWriteUntil = 0; + + isSelfWrite(): boolean { + return Date.now() < this.selfWriteUntil; + } async load(filePath: string, isReload = false): Promise { let raw: string; @@ -87,6 +93,7 @@ class JsonStoreImpl { throw e; } } + this.selfWriteUntil = Date.now() + 600; } catch (err) { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); const message = err instanceof Error ? err.message : String(err);