[
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.ts linguist-language=JavaScript"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: typicode\n"
  },
  {
    "path": ".github/workflows/node.js.yml",
    "content": "name: Node.js CI\non: [push]\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 10\n      - uses: actions/setup-node@v6\n        with:\n          node-version: \"22.x\"\n          cache: \"pnpm\"\n      - run: pnpm install\n      - run: pnpm run lint\n      - run: pnpm run typecheck\n      - run: pnpm test\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Package to npmjs\non:\n  release:\n    types: [published]\npermissions:\n  id-token: write  # Required for OIDC\n  contents: read\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 10\n      - uses: actions/setup-node@v6\n        with:\n          node-version: \"24.x\"\n          registry-url: \"https://registry.npmjs.org\"\n      - run: pnpm install\n      - run: pnpm publish --provenance --access public --no-git-checks --tag latest\n"
  },
  {
    "path": ".gitignore",
    "content": "**/*.log\n.DS_Store\n.idea\nlib\nnode_modules\npublic/output.css\ntmp\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm test\n"
  },
  {
    "path": ".oxfmtrc.json",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 typicode\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# JSON-Server\n\n[![Node.js CI](https://github.com/typicode/json-server/actions/workflows/node.js.yml/badge.svg)](https://github.com/typicode/json-server/actions/workflows/node.js.yml)\n\n> [!IMPORTANT]\n> Viewing beta v1 documentation – usable but expect breaking changes. For stable version, see [here](https://github.com/typicode/json-server/tree/v0.17.4)\n\n> [!NOTE]\n> Using React ⚛️ and tired of CSS-in-JS? See [MistCSS](https://github.com/typicode/mistcss) 👀\n\n## Install\n\n```shell\nnpm install json-server\n```\n\n## Usage\n\nCreate a `db.json` or `db.json5` file\n\n```json\n{\n  \"$schema\": \"./node_modules/json-server/schema.json\",\n  \"posts\": [\n    { \"id\": \"1\", \"title\": \"a title\", \"views\": 100 },\n    { \"id\": \"2\", \"title\": \"another title\", \"views\": 200 }\n  ],\n  \"comments\": [\n    { \"id\": \"1\", \"text\": \"a comment about post 1\", \"postId\": \"1\" },\n    { \"id\": \"2\", \"text\": \"another comment about post 1\", \"postId\": \"1\" }\n  ],\n  \"profile\": {\n    \"name\": \"typicode\"\n  }\n}\n```\n\n<details>\n\n<summary>View db.json5 example</summary>\n\n```json5\n{\n  posts: [\n    { id: \"1\", title: \"a title\", views: 100 },\n    { id: \"2\", title: \"another title\", views: 200 },\n  ],\n  comments: [\n    { id: \"1\", text: \"a comment about post 1\", postId: \"1\" },\n    { id: \"2\", text: \"another comment about post 1\", postId: \"1\" },\n  ],\n  profile: {\n    name: \"typicode\",\n  },\n}\n```\n\nYou can read more about JSON5 format [here](https://github.com/json5/json5).\n\n</details>\n\nStart JSON Server\n\n```bash\nnpx json-server db.json\n```\n\nThis starts the server at `http://localhost:3000`. You should see:\n```\nJSON Server started on PORT :3000\nhttp://localhost:3000\n```\n\nAccess your REST API:\n\n```bash\ncurl http://localhost:3000/posts/1\n```\n\n**Response:**\n```json\n{\n  \"id\": \"1\",\n  \"title\": \"a title\",\n  \"views\": 100\n}\n```\n\nRun `json-server --help` for a list of options\n\n## Sponsors ✨\n\n### Gold\n\n|                                                                                                                                                            |\n| :--------------------------------------------------------------------------------------------------------------------------------------------------------: |\n|               <a href=\"https://mockend.com/\" target=\"_blank\"><img src=\"https://jsonplaceholder.typicode.com/mockend.svg\" height=\"100px\"></a>               |\n| <a href=\"https://zuplo.link/json-server-gh\"><img src=\"https://github.com/user-attachments/assets/adfee31f-a8b6-4684-9a9b-af4f03ac5b75\" height=\"100px\"></a> |\n|     <a href=\"https://www.mintlify.com/\"><img src=\"https://github.com/user-attachments/assets/bcc8cc48-b2d9-4577-8939-1eb4196b7cc5\" height=\"100px\"></a>     |\n| <a href=\"http://git-tower.com/?utm_source=husky&utm_medium=referral\"><img height=\"100px\" alt=\"tower-dock-icon-light\" src=\"https://jsonplaceholder.typicode.com/tower-icon-and-logo-1400x260.png\" /></a> |\n| <a href=\"https://serpapi.com/?utm_source=typicode\"><img height=\"100px\" src=\"https://github.com/user-attachments/assets/52b3039d-1e4c-4c68-951c-93f0f1e73611\" /></a>\n\n\n### Silver\n\n|                                                                                                                                                                                                                                         |\n| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |\n| <a href=\"https://requestly.com?utm_source=githubsponsor&utm_medium=jsonserver&utm_campaign=jsonserver\"><img src=\"https://github.com/user-attachments/assets/f7e7b3cf-97e2-46b8-81c8-cb3992662a1c\" style=\"height:70px; width:auto;\"></a> |\n\n### Bronze\n\n|                                                                                                                                                                                |                                                                                                                                                                              |\n| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |\n| <a href=\"https://www.storyblok.com/\" target=\"_blank\"><img src=\"https://github.com/typicode/json-server/assets/5502029/c6b10674-4ada-4616-91b8-59d30046b45a\" height=\"35px\"></a> | <a href=\"https://betterstack.com/\" target=\"_blank\"><img src=\"https://github.com/typicode/json-server/assets/5502029/44679f8f-9671-470d-b77e-26d90b90cbdc\" height=\"35px\"></a> |\n\n[Become a sponsor and have your company logo here](https://github.com/users/typicode/sponsorship)\n\n## Query Capabilities\n\nJSON Server supports advanced querying out of the box:\n\n```http\nGET /posts?views:gt=100                  # Filter by condition\nGET /posts?_sort=-views                  # Sort by field (descending)\nGET /posts?_page=1&_per_page=10          # Pagination\nGET /posts?_embed=comments               # Include relations\nGET /posts?_where={\"or\":[...]}           # Complex queries\n```\n\nSee detailed documentation below for each feature.\n\n## Routes\n\n### Array Resources\n\nFor array resources like `posts` and `comments`:\n\n```http\nGET    /posts\nGET    /posts/:id\nPOST   /posts\nPUT    /posts/:id\nPATCH  /posts/:id\nDELETE /posts/:id\n```\n\n### Object Resources\n\nFor singular object resources like `profile`:\n\n```http\nGET   /profile\nPUT   /profile\nPATCH /profile\n```\n\n## Query params\n\n### Conditions\n\nUse `field:operator=value`.\n\nOperators:\n\n- no operator -> `eq` (equal)\n- `lt` less than, `lte` less than or equal\n- `gt` greater than, `gte` greater than or equal\n- `eq` equal, `ne` not equal\n- `in` included in comma-separated list\n- `contains` string contains (case-insensitive)\n- `startsWith` string starts with (case-insensitive)\n- `endsWith` string ends with (case-insensitive)\n\nExamples:\n\n```http\nGET /posts?views:gt=100\nGET /posts?title:eq=Hello\nGET /posts?id:in=1,2,3\nGET /posts?author.name:eq=typicode\nGET /posts?title:contains=hello\nGET /posts?title:startsWith=Hello\nGET /posts?title:endsWith=world\n```\n\n### Sort\n\n```http\nGET /posts?_sort=title\nGET /posts?_sort=-views\nGET /posts?_sort=author.name,-views\n```\n\n### Pagination\n\n```http\nGET /posts?_page=1&_per_page=25\n```\n\n**Response:**\n```json\n{\n  \"first\": 1,\n  \"prev\": null,\n  \"next\": 2,\n  \"last\": 4,\n  \"pages\": 4,\n  \"items\": 100,\n  \"data\": [\n    { \"id\": \"1\", \"title\": \"...\", \"views\": 100 },\n    { \"id\": \"2\", \"title\": \"...\", \"views\": 200 }\n  ]\n}\n```\n\n**Notes:**\n- `_per_page` defaults to `10` if not specified\n- Invalid `_page` or `_per_page` values are automatically normalized to valid ranges\n\n### Embed\n\n```http\nGET /posts?_embed=comments\nGET /comments?_embed=post\n```\n\n### Complex filter with `_where`\n\n`_where` accepts a JSON object and overrides normal query params when valid.\n\n```http\nGET /posts?_where={\"or\":[{\"views\":{\"gt\":100}},{\"author\":{\"name\":{\"lt\":\"m\"}}}]}\n```\n\n## Delete dependents\n\n```http\nDELETE /posts/1?_dependent=comments\n```\n\n## Static Files\n\nJSON Server automatically serves files from the `./public` directory.\n\nTo serve additional static directories:\n\n```bash\njson-server db.json -s ./static\njson-server db.json -s ./static -s ./node_modules\n```\n\nStatic files are served with standard MIME types and can include HTML, CSS, JavaScript, images, and other assets.\n\n## Migration Notes (v0 → v1)\n\nIf you are upgrading from json-server v0.x, note these behavioral changes:\n\n- **ID handling:** `id` is always a string and will be auto-generated if not provided\n- **Pagination:** Use `_per_page` with `_page` instead of the deprecated `_limit` parameter\n- **Relationships:** Use `_embed` instead of `_expand` for including related resources\n- **Request delays:** Use browser DevTools (Network tab > throttling) instead of the removed `--delay` CLI option\n\n> **New to json-server?** These notes are for users migrating from v0. If this is your first time using json-server, you can ignore this section.\n"
  },
  {
    "path": "fixtures/db.json",
    "content": "{\n  \"posts\": [\n    { \"id\": \"1\", \"title\": \"a title\" },\n    { \"id\": \"2\", \"title\": \"another title\" }\n  ],\n  \"comments\": [\n    { \"id\": \"1\", \"text\": \"a comment about post 1\", \"postId\": \"1\" },\n    { \"id\": \"2\", \"text\": \"another comment about post 1\", \"postId\": \"1\" }\n  ],\n  \"profile\": {\n    \"name\": \"typicode\"\n  }\n}\n"
  },
  {
    "path": "fixtures/db.json5",
    "content": "{\n  posts: [\n    {\n      id: \"1\",\n      title: \"a title\",\n    },\n    {\n      id: \"2\",\n      title: \"another title\",\n    },\n  ],\n  comments: [\n    {\n      id: \"1\",\n      text: \"a comment about post 1\",\n      postId: \"1\",\n    },\n    {\n      id: \"2\",\n      text: \"another comment about post 1\",\n      postId: \"1\",\n    },\n  ],\n  profile: {\n    name: \"typicode\",\n  },\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"json-server\",\n  \"version\": \"1.0.0-beta.13\",\n  \"description\": \"\",\n  \"keywords\": [\n    \"JSON\",\n    \"server\",\n    \"fake\",\n    \"REST\",\n    \"API\",\n    \"prototyping\",\n    \"mock\",\n    \"mocking\",\n    \"test\",\n    \"testing\",\n    \"rest\",\n    \"data\",\n    \"dummy\"\n  ],\n  \"license\": \"MIT\",\n  \"author\": \"typicode <typicode@gmail.com>\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/typicode/json-server.git\"\n  },\n  \"bin\": {\n    \"json-server\": \"lib/bin.js\"\n  },\n  \"files\": [\n    \"lib\",\n    \"views\",\n    \"schema.json\"\n  ],\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"node --watch --experimental-strip-types src/bin.ts fixtures/db.json\",\n    \"build\": \"rm -rf lib && tsc\",\n    \"prepublishOnly\": \"rm -rf lib && tsc\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"test\": \"node --experimental-strip-types --test src/*.test.ts\",\n    \"lint\": \"oxlint src\",\n    \"fmt\": \"oxfmt\",\n    \"fmt:check\": \"oxfmt --check\",\n    \"prepare\": \"husky\"\n  },\n  \"dependencies\": {\n    \"@tinyhttp/app\": \"^3.0.1\",\n    \"@tinyhttp/cors\": \"^2.0.1\",\n    \"@tinyhttp/logger\": \"^2.1.0\",\n    \"chalk\": \"^5.6.2\",\n    \"chokidar\": \"^5.0.0\",\n    \"dot-prop\": \"^10.1.0\",\n    \"eta\": \"^4.5.0\",\n    \"inflection\": \"^3.0.2\",\n    \"json5\": \"^2.2.3\",\n    \"lowdb\": \"^7.0.1\",\n    \"milliparsec\": \"^5.1.0\",\n    \"sirv\": \"^3.0.2\",\n    \"sort-on\": \"^7.0.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^25.0.8\",\n    \"concurrently\": \"^9.2.1\",\n    \"get-port\": \"^7.1.0\",\n    \"husky\": \"^9.1.7\",\n    \"oxfmt\": \"^0.24.0\",\n    \"oxlint\": \"^1.39.0\",\n    \"tempy\": \"^3.1.0\",\n    \"type-fest\": \"^5.4.0\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  }\n}\n"
  },
  {
    "path": "public/test.html",
    "content": "<!-- Testing automatic serving of files from the 'public/' directory -->\n"
  },
  {
    "path": "schema.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"additionalProperties\": {\n    \"oneOf\": [\n      { \"type\": \"array\" },\n      { \"type\": \"object\" }\n    ]\n  }\n}\n"
  },
  {
    "path": "src/adapters/normalized-adapter.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test from 'node:test'\n\nimport type { Adapter } from 'lowdb'\n\nimport { DEFAULT_SCHEMA_PATH, NormalizedAdapter } from './adapters/normalized-adapter.ts'\nimport type { RawData } from './adapters/normalized-adapter.ts'\nimport type { Data } from './service.ts'\n\nclass StubAdapter implements Adapter<RawData> {\n  #data: RawData | null\n\n  constructor(data: RawData | null) {\n    this.#data = data\n  }\n\n  async read(): Promise<RawData | null> {\n    return this.#data === null ? null : structuredClone(this.#data)\n  }\n\n  async write(data: RawData): Promise<void> {\n    this.#data = structuredClone(data)\n  }\n\n  get data(): RawData | null {\n    return this.#data\n  }\n}\n\nawait test('read removes $schema and normalizes ids', async () => {\n  const adapter = new StubAdapter({\n    $schema: './custom/schema.json',\n    posts: [{ id: 1 }, { title: 'missing id' }],\n    profile: { name: 'x' },\n  })\n\n  const normalized = await new NormalizedAdapter(adapter).read()\n  assert.notEqual(normalized, null)\n\n  if (normalized === null) {\n    return\n  }\n\n  assert.equal(normalized['$schema'], undefined)\n  assert.deepEqual(normalized['profile'], { name: 'x' })\n\n  const posts = normalized['posts']\n  assert.ok(Array.isArray(posts))\n  assert.equal(posts[0]?.['id'], '1')\n  assert.equal(typeof posts[1]?.['id'], 'string')\n  assert.notEqual(posts[1]?.['id'], '')\n})\n\nawait test('write always overwrites $schema', async () => {\n  const adapter = new StubAdapter(null)\n  const normalizedAdapter = new NormalizedAdapter(adapter)\n\n  await normalizedAdapter.write({ posts: [{ id: '1' }] } satisfies Data)\n\n  const data = adapter.data\n  assert.notEqual(data, null)\n  assert.equal(data?.['$schema'], DEFAULT_SCHEMA_PATH)\n})\n"
  },
  {
    "path": "src/adapters/normalized-adapter.ts",
    "content": "import type { Adapter } from 'lowdb'\n\nimport { randomId } from '../random-id.ts'\nimport type { Data, Item } from '../service.ts'\n\nexport const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json'\nexport type RawData = Record<string, Item[] | Item | string | undefined> & {\n  $schema?: string\n}\n\nexport class NormalizedAdapter implements Adapter<Data> {\n  #adapter: Adapter<RawData>\n\n  constructor(adapter: Adapter<RawData>) {\n    this.#adapter = adapter\n  }\n\n  async read(): Promise<Data | null> {\n    const data = await this.#adapter.read()\n\n    if (data === null) {\n      return null\n    }\n\n    delete data['$schema']\n\n    for (const value of Object.values(data)) {\n      if (Array.isArray(value)) {\n        for (const item of value) {\n          if (typeof item['id'] === 'number') {\n            item['id'] = item['id'].toString()\n          }\n\n          if (item['id'] === undefined) {\n            item['id'] = randomId()\n          }\n        }\n      }\n    }\n\n    return data as Data\n  }\n\n  async write(data: Data): Promise<void> {\n    await this.#adapter.write({ ...data, $schema: DEFAULT_SCHEMA_PATH })\n  }\n}\n"
  },
  {
    "path": "src/adapters/observer.ts",
    "content": "import type { Adapter } from 'lowdb'\n\n// Lowdb adapter to observe read/write events\nexport class Observer<T> {\n  #adapter: Adapter<T>\n\n  onReadStart = function () {\n    return\n  }\n  onReadEnd: (data: T | null) => void = function () {\n    return\n  }\n  onWriteStart = function () {\n    return\n  }\n  onWriteEnd = function () {\n    return\n  }\n\n  constructor(adapter: Adapter<T>) {\n    this.#adapter = adapter\n  }\n\n  async read() {\n    this.onReadStart()\n    const data = await this.#adapter.read()\n    this.onReadEnd(data)\n    return data\n  }\n\n  async write(arg: T) {\n    this.onWriteStart()\n    await this.#adapter.write(arg)\n    this.onWriteEnd()\n  }\n}\n"
  },
  {
    "path": "src/app.test.ts",
    "content": "   import assert from 'node:assert/strict'\nimport { writeFileSync } from 'node:fs'\nimport { join } from 'node:path'\nimport test from 'node:test'\n\nimport getPort from 'get-port'\nimport { Low, Memory } from 'lowdb'\nimport { temporaryDirectory } from 'tempy'\n\nimport { createApp } from './app.ts'\nimport type { Data } from './service.ts'\n\ntype Test = {\n  method: HTTPMethods\n  url: string\n  statusCode: number\n}\n\ntype HTTPMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS'\n\nconst port = await getPort()\n\n// Create custom static dir with an html file\nconst tmpDir = temporaryDirectory()\nconst file = 'file.html'\nwriteFileSync(join(tmpDir, file), 'utf-8')\n\n// Create app\nconst db = new Low<Data>(new Memory<Data>(), {})\ndb.data = {\n  posts: [{ id: '1', title: 'foo' }],\n  comments: [{ id: '1', postId: '1' }],\n  object: { f1: 'foo' },\n}\nconst app = createApp(db, { static: [tmpDir] })\n\nawait new Promise<void>((resolve, reject) => {\n  try {\n    const server = app.listen(port, () => resolve())\n    test.after(() => server.close())\n  } catch (err) {\n    reject(err)\n  }\n})\n\nawait test('createApp', async (t) => {\n  // URLs\n  const POSTS = '/posts'\n  const POSTS_WITH_COMMENTS = '/posts?_embed=comments'\n  const POST_1 = '/posts/1'\n  const POST_NOT_FOUND = '/posts/-1'\n  const POST_WITH_COMMENTS = '/posts/1?_embed=comments'\n  const COMMENTS = '/comments'\n  const POST_COMMENTS = '/comments?postId=1'\n  const NOT_FOUND = '/not-found'\n  const OBJECT = '/object'\n  const OBJECT_1 = '/object/1'\n\n  const arr: Test[] = [\n    // Static\n    { method: 'GET', url: '/', statusCode: 200 },\n    { method: 'GET', url: '/test.html', statusCode: 200 },\n    { method: 'GET', url: `/${file}`, statusCode: 200 },\n\n    // CORS\n    { method: 'OPTIONS', url: POSTS, statusCode: 204 },\n\n    // API\n    { method: 'GET', url: POSTS, statusCode: 200 },\n    { method: 'GET', url: POSTS_WITH_COMMENTS, statusCode: 200 },\n    { method: 'GET', url: POST_1, statusCode: 200 },\n    { method: 'GET', url: POST_NOT_FOUND, statusCode: 404 },\n    { method: 'GET', url: POST_WITH_COMMENTS, statusCode: 200 },\n    { method: 'GET', url: COMMENTS, statusCode: 200 },\n    { method: 'GET', url: POST_COMMENTS, statusCode: 200 },\n    { method: 'GET', url: OBJECT, statusCode: 200 },\n    { method: 'GET', url: OBJECT_1, statusCode: 404 },\n    { method: 'GET', url: NOT_FOUND, statusCode: 404 },\n\n    { method: 'POST', url: POSTS, statusCode: 201 },\n    { method: 'POST', url: POST_1, statusCode: 404 },\n    { method: 'POST', url: POST_NOT_FOUND, statusCode: 404 },\n    { method: 'POST', url: OBJECT, statusCode: 404 },\n    { method: 'POST', url: OBJECT_1, statusCode: 404 },\n    { method: 'POST', url: NOT_FOUND, statusCode: 404 },\n\n    { method: 'PUT', url: POSTS, statusCode: 404 },\n    { method: 'PUT', url: POST_1, statusCode: 200 },\n    { method: 'PUT', url: OBJECT, statusCode: 200 },\n    { method: 'PUT', url: OBJECT_1, statusCode: 404 },\n    { method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 },\n    { method: 'PUT', url: NOT_FOUND, statusCode: 404 },\n\n    { method: 'PATCH', url: POSTS, statusCode: 404 },\n    { method: 'PATCH', url: POST_1, statusCode: 200 },\n    { method: 'PATCH', url: OBJECT, statusCode: 200 },\n    { method: 'PATCH', url: OBJECT_1, statusCode: 404 },\n    { method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 },\n    { method: 'PATCH', url: NOT_FOUND, statusCode: 404 },\n\n    { method: 'DELETE', url: POSTS, statusCode: 404 },\n    { method: 'DELETE', url: POST_1, statusCode: 200 },\n    { method: 'DELETE', url: OBJECT, statusCode: 404 },\n    { method: 'DELETE', url: OBJECT_1, statusCode: 404 },\n    { method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 },\n    { method: 'DELETE', url: NOT_FOUND, statusCode: 404 },\n  ]\n\n  for (const tc of arr) {\n    await t.test(`${tc.method} ${tc.url}`, async () => {\n      const response = await fetch(`http://localhost:${port}${tc.url}`, {\n        method: tc.method,\n      })\n      assert.equal(\n        response.status,\n        tc.statusCode,\n        `${response.status} !== ${tc.statusCode} ${tc.method} ${tc.url} failed`,\n      )\n    })\n  }\n\n  await t.test('GET /posts?_where=... uses JSON query', async () => {\n    // Reset data since previous tests may have modified it\n    db.data = {\n      posts: [{ id: '1', title: 'foo' }],\n      comments: [{ id: '1', postId: '1' }],\n      object: { f1: 'foo' },\n    }\n    const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))\n    const response = await fetch(`http://localhost:${port}/posts?_where=${where}`)\n    assert.equal(response.status, 200)\n    const data = await response.json()\n    assert.deepEqual(data, [{ id: '1', title: 'foo' }])\n  })\n\n  await t.test('GET /posts?_where=... overrides query params', async () => {\n    const where = encodeURIComponent(JSON.stringify({ title: { eq: 'foo' } }))\n    const response = await fetch(\n      `http://localhost:${port}/posts?title:eq=bar&_where=${where}`,\n    )\n    assert.equal(response.status, 200)\n    const data = await response.json()\n    assert.deepEqual(data, [{ id: '1', title: 'foo' }])\n  })\n\n  await t.test('POST /posts with array body returns 400', async () => {\n    const response = await fetch(`http://localhost:${port}/posts`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify([{ title: 'foo' }]),\n    })\n    assert.equal(response.status, 400)\n    const data = await response.json()\n    assert.deepEqual(data, { error: 'Body must be a JSON object' })\n  })\n\n  await t.test('PATCH /posts/1 with string body returns 400', async () => {\n    const response = await fetch(`http://localhost:${port}/posts/1`, {\n      method: 'PATCH',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify('hello'),\n    })\n    assert.equal(response.status, 400)\n    const data = await response.json()\n    assert.deepEqual(data, { error: 'Body must be a JSON object' })\n  })\n\n  await t.test('PUT /posts/1 with null body returns 400', async () => {\n    const response = await fetch(`http://localhost:${port}/posts/1`, {\n      method: 'PUT',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(null),\n    })\n    assert.equal(response.status, 400)\n    const data = await response.json()\n    assert.deepEqual(data, { error: 'Body must be a JSON object' })\n  })\n})\n"
  },
  {
    "path": "src/app.ts",
    "content": "import { dirname, isAbsolute, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nimport { App } from '@tinyhttp/app'\nimport { cors } from '@tinyhttp/cors'\nimport { Eta } from 'eta'\nimport { Low } from 'lowdb'\nimport { json } from 'milliparsec'\nimport sirv from 'sirv'\n\nimport { parseWhere } from './parse-where.ts'\nimport type { Data } from './service.ts'\nimport { isItem, Service } from './service.ts'\n\nconst __dirname = dirname(fileURLToPath(import.meta.url))\nconst isProduction = process.env['NODE_ENV'] === 'production'\n\nexport type AppOptions = {\n  logger?: boolean\n  static?: string[]\n}\n\nconst eta = new Eta({\n  views: join(__dirname, '../views'),\n  cache: isProduction,\n})\n\nconst RESERVED_QUERY_KEYS = new Set(['_sort', '_page', '_per_page', '_embed', '_where'])\n\nfunction parseListParams(req: any) {\n  const queryString = req.url.split('?')[1] ?? ''\n  const params = new URLSearchParams(queryString)\n\n  const filterParams = new URLSearchParams()\n  for (const [key, value] of params.entries()) {\n    if (!RESERVED_QUERY_KEYS.has(key)) {\n      filterParams.append(key, value)\n    }\n  }\n\n  let where = parseWhere(filterParams.toString())\n  const rawWhere = params.get('_where')\n  if (typeof rawWhere === 'string') {\n    try {\n      const parsed = JSON.parse(rawWhere)\n      if (typeof parsed === 'object' && parsed !== null) {\n        where = parsed\n      }\n    } catch {\n      // Ignore invalid JSON and fallback to parsed query params\n    }\n  }\n\n  const pageRaw = params.get('_page')\n  const perPageRaw = params.get('_per_page')\n  const page = pageRaw === null ? undefined : Number.parseInt(pageRaw, 10)\n  const perPage = perPageRaw === null ? undefined : Number.parseInt(perPageRaw, 10)\n\n  return {\n    where,\n    sort: params.get('_sort') ?? undefined,\n    page: Number.isNaN(page) ? undefined : page,\n    perPage: Number.isNaN(perPage) ? undefined : perPage,\n    embed: req.query['_embed'],\n  }\n}\n\nfunction withBody(action: (name: string, body: Record<string, unknown>) => Promise<unknown>) {\n  return async (req: any, res: any, next: any) => {\n    const { name = '' } = req.params\n    if (!isItem(req.body)) {\n      res.status(400).json({ error: 'Body must be a JSON object' })\n      return\n    }\n    res.locals['data'] = await action(name, req.body)\n    next?.()\n  }\n}\n\nfunction withIdAndBody(\n  action: (name: string, id: string, body: Record<string, unknown>) => Promise<unknown>,\n) {\n  return async (req: any, res: any, next: any) => {\n    const { name = '', id = '' } = req.params\n    if (!isItem(req.body)) {\n      res.status(400).json({ error: 'Body must be a JSON object' })\n      return\n    }\n    res.locals['data'] = await action(name, id, req.body)\n    next?.()\n  }\n}\n\nexport function createApp(db: Low<Data>, options: AppOptions = {}) {\n  // Create service\n  const service = new Service(db)\n\n  // Create app\n  const app = new App()\n\n  // Static files\n  app.use(sirv('public', { dev: !isProduction }))\n  options.static\n    ?.map((path) => (isAbsolute(path) ? path : join(process.cwd(), path)))\n    .forEach((dir) => app.use(sirv(dir, { dev: !isProduction })))\n\n  // CORS\n  app\n    .use((req, res, next) => {\n      return cors({\n        allowedHeaders: req.headers['access-control-request-headers']\n          ?.split(',')\n          .map((h) => h.trim()),\n      })(req, res, next)\n    })\n    .options('*', cors())\n\n  // Body parser\n  app.use(json())\n\n  app.get('/', (_req, res) => res.send(eta.render('index.html', { data: db.data })))\n\n  app.get('/:name', (req, res, next) => {\n    const { name = '' } = req.params\n    const { where, sort, page, perPage, embed } = parseListParams(req)\n\n    res.locals['data'] = service.find(name, {\n      where,\n      sort,\n      page,\n      perPage,\n      embed,\n    })\n    next?.()\n  })\n\n  app.get('/:name/:id', (req, res, next) => {\n    const { name = '', id = '' } = req.params\n    res.locals['data'] = service.findById(name, id, req.query)\n    next?.()\n  })\n\n  app.post('/:name', withBody(service.create.bind(service)))\n\n  app.put('/:name', withBody(service.update.bind(service)))\n\n  app.put('/:name/:id', withIdAndBody(service.updateById.bind(service)))\n\n  app.patch('/:name', withBody(service.patch.bind(service)))\n\n  app.patch('/:name/:id', withIdAndBody(service.patchById.bind(service)))\n\n  app.delete('/:name/:id', async (req, res, next) => {\n    const { name = '', id = '' } = req.params\n    res.locals['data'] = await service.destroyById(name, id, req.query['_dependent'])\n    next?.()\n  })\n\n  app.use('/:name', (req, res) => {\n    const { data } = res.locals\n    if (data === undefined) {\n      res.sendStatus(404)\n    } else {\n      if (req.method === 'POST') res.status(201)\n      res.json(data)\n    }\n  })\n\n  return app\n}\n"
  },
  {
    "path": "src/bin.ts",
    "content": "#!/usr/bin/env node\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { extname } from \"node:path\";\nimport { parseArgs } from \"node:util\";\n\nimport chalk from \"chalk\";\nimport { watch } from \"chokidar\";\nimport JSON5 from \"json5\";\nimport { Low } from \"lowdb\";\nimport type { Adapter } from \"lowdb\";\nimport { DataFile, JSONFile } from \"lowdb/node\";\nimport type { PackageJson } from \"type-fest\";\n\nimport { fileURLToPath } from \"node:url\";\nimport { NormalizedAdapter } from \"./adapters/normalized-adapter.ts\";\nimport type { RawData } from \"./adapters/normalized-adapter.ts\";\nimport { Observer } from \"./adapters/observer.ts\";\nimport { createApp } from \"./app.ts\";\nimport type { Data } from \"./service.ts\";\n\nfunction help() {\n  console.log(`Usage: json-server [options] <file>\n\nOptions:\n  -p, --port <port>  Port (default: 3000)\n  -h, --host <host>  Host (default: localhost)\n  -s, --static <dir> Static files directory (multiple allowed)\n  --help             Show this message\n  --version          Show version number\n`);\n}\n\n// Parse args\nfunction args(): {\n  file: string;\n  port: number;\n  host: string;\n  static: string[];\n} {\n  try {\n    const { values, positionals } = parseArgs({\n      options: {\n        port: {\n          type: \"string\",\n          short: \"p\",\n          default: process.env[\"PORT\"] ?? \"3000\",\n        },\n        host: {\n          type: \"string\",\n          short: \"h\",\n          default: process.env[\"HOST\"] ?? \"localhost\",\n        },\n        static: {\n          type: \"string\",\n          short: \"s\",\n          multiple: true,\n          default: [],\n        },\n        help: {\n          type: \"boolean\",\n        },\n        version: {\n          type: \"boolean\",\n        },\n        // Deprecated\n        watch: {\n          type: \"boolean\",\n          short: \"w\",\n        },\n      },\n      allowPositionals: true,\n    });\n\n    // --version\n    if (values.version) {\n      const pkg = JSON.parse(\n        readFileSync(fileURLToPath(new URL(\"../package.json\", import.meta.url)), \"utf-8\"),\n      ) as PackageJson;\n      console.log(pkg.version);\n      process.exit();\n    }\n\n    // Handle --watch\n    if (values.watch) {\n      console.log(\n        chalk.yellow(\n          \"--watch/-w can be omitted, JSON Server 1+ watches for file changes by default\",\n        ),\n      );\n    }\n\n    if (values.help || positionals.length === 0) {\n      help();\n      process.exit();\n    }\n\n    // App args and options\n    return {\n      file: positionals[0] ?? \"\",\n      port: parseInt(values.port as string),\n      host: values.host as string,\n      static: values.static as string[],\n    };\n  } catch (e) {\n    if ((e as NodeJS.ErrnoException).code === \"ERR_PARSE_ARGS_UNKNOWN_OPTION\") {\n      console.log(chalk.red((e as NodeJS.ErrnoException).message.split(\".\")[0]));\n      help();\n      process.exit(1);\n    } else {\n      throw e;\n    }\n  }\n}\n\nconst { file, port, host, static: staticArr } = args();\n\nif (!existsSync(file)) {\n  console.log(chalk.red(`File ${file} not found`));\n  process.exit(1);\n}\n\n// Handle empty string JSON file\nif (readFileSync(file, \"utf-8\").trim() === \"\") {\n  writeFileSync(file, \"{}\");\n}\n\n// Set up database\nlet adapter: Adapter<RawData>;\nif (extname(file) === \".json5\") {\n  adapter = new DataFile<RawData>(file, {\n    parse: JSON5.parse,\n    stringify: JSON5.stringify,\n  });\n} else {\n  adapter = new JSONFile<RawData>(file);\n}\nconst observer = new Observer(new NormalizedAdapter(adapter));\n\nconst db = new Low<Data>(observer, {});\nawait db.read();\n\n// Create app\nconst app = createApp(db, { logger: false, static: staticArr });\n\nfunction logRoutes(data: Data) {\n  console.log(chalk.bold(\"Endpoints:\"));\n  if (Object.keys(data).length === 0) {\n    console.log(chalk.gray(`No endpoints found, try adding some data to ${file}`));\n    return;\n  }\n  console.log(\n    Object.keys(data)\n      .map((key) => `${chalk.gray(`http://${host}:${port}/`)}${chalk.blue(key)}`)\n      .join(\"\\n\"),\n  );\n}\n\nconst kaomojis = [\"♡⸜(˶˃ ᵕ ˂˶)⸝♡\", \"♡( ◡‿◡ )\", \"( ˶ˆ ᗜ ˆ˵ )\", \"(˶ᵔ ᵕ ᵔ˶)\"];\n\nfunction randomItem(items: string[]): string {\n  const index = Math.floor(Math.random() * items.length);\n  return items.at(index) ?? \"\";\n}\n\napp.listen(port, () => {\n  console.log(\n    [\n      chalk.bold(`JSON Server started on PORT :${port}`),\n      chalk.gray(\"Press CTRL-C to stop\"),\n      chalk.gray(`Watching ${file}...`),\n      \"\",\n      chalk.magenta(randomItem(kaomojis)),\n      \"\",\n      chalk.bold(\"Index:\"),\n      chalk.gray(`http://localhost:${port}/`),\n      \"\",\n      chalk.bold(\"Static files:\"),\n      chalk.gray(\"Serving ./public directory if it exists\"),\n      \"\",\n    ].join(\"\\n\"),\n  );\n  logRoutes(db.data);\n});\n\n// Watch file for changes\nif (process.env[\"NODE_ENV\"] !== \"production\") {\n  let writing = false; // true if the file is being written to by the app\n  let hadReadError = false;\n  let prevEndpoints = \"\";\n\n  observer.onWriteStart = () => {\n    writing = true;\n  };\n  observer.onWriteEnd = () => {\n    writing = false;\n  };\n  observer.onReadStart = () => {\n    prevEndpoints = JSON.stringify(Object.keys(db.data).sort());\n  };\n  observer.onReadEnd = (data) => {\n    if (data === null) {\n      return;\n    }\n\n    const nextEndpoints = JSON.stringify(Object.keys(data).sort());\n    if (hadReadError || prevEndpoints !== nextEndpoints) {\n      console.log();\n      logRoutes(data);\n    }\n    hadReadError = false;\n  };\n  watch(file).on(\"change\", () => {\n    // Do no reload if the file is being written to by the app\n    if (!writing) {\n      db.read().catch((e) => {\n        if (e instanceof SyntaxError) {\n          hadReadError = true;\n          return console.log(chalk.red([\"\", `Error parsing ${file}`, e.message].join(\"\\n\")));\n        }\n        console.log(e);\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "src/matches-where.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test from 'node:test'\n\nimport type { JsonObject } from 'type-fest'\n\nimport { matchesWhere } from './matches-where.ts'\n\nawait test('matchesWhere', async (t) => {\n  const obj: JsonObject = { a: 10, b: 20, c: 'x', nested: { a: 10, b: 20 } }\n  const cases: [JsonObject, boolean][] = [\n    [{ a: { eq: 10 } }, true],\n    [{ a: { eq: 11 } }, false],\n    [{ c: { ne: 'y' } }, true],\n    [{ c: { ne: 'x' } }, false],\n    [{ a: { lt: 11 } }, true],\n    [{ a: { lt: 10 } }, false],\n    [{ a: { lte: 10 } }, true],\n    [{ a: { lte: 9 } }, false],\n    [{ b: { gt: 19 } }, true],\n    [{ b: { gt: 20 } }, false],\n    [{ b: { gte: 20 } }, true],\n    [{ b: { gte: 21 } }, false],\n    [{ a: { gt: 0 }, b: { lt: 30 } }, true],\n    [{ a: { gt: 10 }, b: { lt: 30 } }, false],\n    [{ or: [{ a: { lt: 0 } }, { b: { gt: 19 } }] }, true],\n    [{ or: [{ a: { lt: 0 } }, { b: { gt: 20 } }] }, false],\n    [{ nested: { a: { eq: 10 } } }, true],\n    [{ nested: { b: { lt: 20 } } }, false],\n    [{ a: { in: [10, 11] } }, true],\n    [{ a: { in: [1, 2] } }, false],\n    [{ c: { in: ['x', 'y'] } }, true],\n    [{ c: { in: ['y', 'z'] } }, false],\n    [{ a: { in: [10, 11], gt: 9 } }, true],\n    [{ a: { in: [10, 11], gt: 10 } }, false],\n    [{ a: { foo: 10 } }, true],\n    [{ a: { foo: 10, eq: 10 } }, true],\n    [{ missing: { foo: 1 } }, true],\n    // contains\n    [{ c: { contains: 'x' } }, true],\n    [{ c: { contains: 'X' } }, true],\n    [{ c: { contains: 'z' } }, false],\n    [{ a: { contains: '1' } }, false],\n    [{ c: { contains: 1 } }, false],\n    // startsWith\n    [{ c: { startsWith: 'x' } }, true],\n    [{ c: { startsWith: 'X' } }, true],\n    [{ c: { startsWith: 'z' } }, false],\n    [{ a: { startsWith: '1' } }, false],\n    [{ c: { startsWith: 1 } }, false],\n    // endsWith\n    [{ c: { endsWith: 'x' } }, true],\n    [{ c: { endsWith: 'X' } }, true],\n    [{ c: { endsWith: 'z' } }, false],\n    [{ a: { endsWith: '1' } }, false],\n    [{ c: { endsWith: 1 } }, false],\n  ]\n\n  for (const [query, expected] of cases) {\n    await t.test(JSON.stringify(query), () => {\n      assert.equal(matchesWhere(obj, query), expected)\n    })\n  }\n})\n"
  },
  {
    "path": "src/matches-where.ts",
    "content": "import type { JsonObject } from 'type-fest'\n\nimport { WHERE_OPERATORS, type WhereOperator } from './where-operators.ts'\n\ntype OperatorObject = Partial<Record<WhereOperator, unknown>>\n\nfunction isJSONObject(value: unknown): value is JsonObject {\n  return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction getKnownOperators(value: unknown): WhereOperator[] {\n  if (!isJSONObject(value)) return []\n\n  const ops: WhereOperator[] = []\n  for (const op of WHERE_OPERATORS) {\n    if (op in value) {\n      ops.push(op)\n    }\n  }\n\n  return ops\n}\n\nexport function matchesWhere(obj: JsonObject, where: JsonObject): boolean {\n  for (const [key, value] of Object.entries(where)) {\n    if (key === 'or') {\n      if (!Array.isArray(value) || value.length === 0) return false\n\n      let matched = false\n      for (const subWhere of value) {\n        if (isJSONObject(subWhere) && matchesWhere(obj, subWhere)) {\n          matched = true\n          break\n        }\n      }\n\n      if (!matched) return false\n      continue\n    }\n\n    const field = (obj as Record<string, unknown>)[key]\n\n    if (isJSONObject(value)) {\n      const knownOps = getKnownOperators(value)\n\n      if (knownOps.length > 0) {\n        if (field === undefined) return false\n\n        const op = value as OperatorObject\n        if (knownOps.includes('lt') && !((field as any) < (op.lt as any))) return false\n        if (knownOps.includes('lte') && !((field as any) <= (op.lte as any))) return false\n        if (knownOps.includes('gt') && !((field as any) > (op.gt as any))) return false\n        if (knownOps.includes('gte') && !((field as any) >= (op.gte as any))) return false\n        if (knownOps.includes('eq') && !((field as any) === (op.eq as any))) return false\n        if (knownOps.includes('ne') && !((field as any) !== (op.ne as any))) return false\n        if (knownOps.includes('in')) {\n          const inValues = Array.isArray(op.in) ? op.in : [op.in]\n          if (!inValues.some((v) => (field as any) === (v as any))) return false\n        }\n        if (knownOps.includes('contains')) {\n          if (typeof field !== 'string') return false\n          if (!field.toLowerCase().includes(String(op.contains).toLowerCase())) return false\n        }\n        if (knownOps.includes('startsWith')) {\n          if (typeof field !== 'string') return false\n          if (!field.toLowerCase().startsWith(String(op.startsWith).toLowerCase())) return false\n        }\n        if (knownOps.includes('endsWith')) {\n          if (typeof field !== 'string') return false\n          if (!field.toLowerCase().endsWith(String(op.endsWith).toLowerCase())) return false\n        }\n        continue\n      }\n\n      if (isJSONObject(field)) {\n        if (!matchesWhere(field, value)) return false\n      }\n\n      continue\n    }\n\n    if (field === undefined) return false\n\n    return false\n  }\n\n  return true\n}\n"
  },
  {
    "path": "src/paginate.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test from 'node:test'\n\nimport { paginate } from './paginate.ts'\n\nawait test('paginate', async (t) => {\n  // Pagination: page boundaries and clamping behavior.\n  const cases = [\n    {\n      name: 'page=1 perPage=2 items=5 -> [1,2]',\n      items: [1, 2, 3, 4, 5],\n      page: 1,\n      perPage: 2,\n      expected: {\n        first: 1,\n        prev: null,\n        next: 2,\n        last: 3,\n        pages: 3,\n        items: 5,\n        data: [1, 2],\n      },\n    },\n    {\n      name: 'page=2 perPage=2 items=5 -> [3,4]',\n      items: [1, 2, 3, 4, 5],\n      page: 2,\n      perPage: 2,\n      expected: {\n        first: 1,\n        prev: 1,\n        next: 3,\n        last: 3,\n        pages: 3,\n        items: 5,\n        data: [3, 4],\n      },\n    },\n    {\n      name: 'page=9 perPage=2 items=5 -> clamp to last',\n      items: [1, 2, 3, 4, 5],\n      page: 9,\n      perPage: 2,\n      expected: {\n        first: 1,\n        prev: 2,\n        next: null,\n        last: 3,\n        pages: 3,\n        items: 5,\n        data: [5],\n      },\n    },\n    {\n      name: 'page=0 perPage=2 items=3 -> clamp to first',\n      items: [1, 2, 3],\n      page: 0,\n      perPage: 2,\n      expected: {\n        first: 1,\n        prev: null,\n        next: 2,\n        last: 2,\n        pages: 2,\n        items: 3,\n        data: [1, 2],\n      },\n    },\n    {\n      name: 'items=[] page=1 perPage=2 -> stable empty pagination',\n      items: [],\n      page: 1,\n      perPage: 2,\n      expected: {\n        first: 1,\n        prev: null,\n        next: null,\n        last: 1,\n        pages: 1,\n        items: 0,\n        data: [],\n      },\n    },\n    {\n      name: 'perPage=0 -> clamp perPage to 1',\n      items: [1, 2, 3],\n      page: 1,\n      perPage: 0,\n      expected: {\n        first: 1,\n        prev: null,\n        next: 2,\n        last: 3,\n        pages: 3,\n        items: 3,\n        data: [1],\n      },\n    },\n  ]\n\n  for (const tc of cases) {\n    await t.test(tc.name, () => {\n      const res = paginate(tc.items, tc.page, tc.perPage)\n      assert.deepEqual(res, tc.expected)\n    })\n  }\n})\n"
  },
  {
    "path": "src/paginate.ts",
    "content": "export type PaginationResult<T> = {\n  first: number\n  prev: number | null\n  next: number | null\n  last: number\n  pages: number\n  items: number\n  data: T[]\n}\n\nexport function paginate<T>(items: T[], page: number, perPage: number): PaginationResult<T> {\n  const totalItems = items.length\n  const safePerPage = Number.isFinite(perPage) && perPage > 0 ? Math.floor(perPage) : 1\n  const pages = Math.max(1, Math.ceil(totalItems / safePerPage))\n\n  // Ensure page is within the valid range\n  const safePage = Number.isFinite(page) ? Math.floor(page) : 1\n  const currentPage = Math.max(1, Math.min(safePage, pages))\n\n  const first = 1\n  const prev = currentPage > 1 ? currentPage - 1 : null\n  const next = currentPage < pages ? currentPage + 1 : null\n  const last = pages\n\n  const start = (currentPage - 1) * safePerPage\n  const end = start + safePerPage\n  const data = items.slice(start, end)\n\n  return {\n    first,\n    prev,\n    next,\n    last,\n    pages,\n    items: totalItems,\n    data,\n  }\n}\n"
  },
  {
    "path": "src/parse-where.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test from 'node:test'\n\nimport { parseWhere } from './parse-where.ts'\n\nawait test('parseWhere', async (t) => {\n  const cases: [string, Record<string, unknown>][] = [\n    [\n      'views:gt=100&title:eq=a',\n      {\n        views: { gt: 100 },\n        title: { eq: 'a' },\n      },\n    ],\n    [\n      'title=hello',\n      {\n        title: { eq: 'hello' },\n      },\n    ],\n    [\n      'author.name:lt=c&author.id:ne=2',\n      {\n        author: {\n          name: { lt: 'c' },\n          id: { ne: 2 },\n        },\n      },\n    ],\n    [\n      'views:gt=100&views:lt=300',\n      {\n        views: { gt: 100, lt: 300 },\n      },\n    ],\n    [\n      'name:eq=Alice',\n      {\n        name: { eq: 'Alice' },\n      },\n    ],\n    [\n      'views:gt=100&published:eq=true&ratio:lt=0.5&deleted:eq=null',\n      {\n        views: { gt: 100 },\n        published: { eq: true },\n        ratio: { lt: 0.5 },\n        deleted: { eq: null },\n      },\n    ],\n    [\n      'views:foo=100&title:eq=a',\n      {\n        title: { eq: 'a' },\n      },\n    ],\n    ['views:foo=100', {}],\n    [\n      'views_gt=100&title_eq=a',\n      {\n        views: { gt: 100 },\n        title: { eq: 'a' },\n      },\n    ],\n    [\n      'first_name_eq=Alice&author.first_name_ne=Bob',\n      {\n        first_name: { eq: 'Alice' },\n        author: {\n          first_name: { ne: 'Bob' },\n        },\n      },\n    ],\n    [\n      'first_name=Alice',\n      {\n        first_name: { eq: 'Alice' },\n      },\n    ],\n    [\n      'views_gt=100&views:lt=300',\n      {\n        views: { gt: 100, lt: 300 },\n      },\n    ],\n    [\n      'id:in=1,3',\n      {\n        id: { in: [1, 3] },\n      },\n    ],\n    [\n      'title_in=hello,world',\n      {\n        title: { in: ['hello', 'world'] },\n      },\n    ],\n    [\n      'title:contains=ello',\n      {\n        title: { contains: 'ello' },\n      },\n    ],\n    [\n      'title:startsWith=hel',\n      {\n        title: { startsWith: 'hel' },\n      },\n    ],\n    [\n      'title:endsWith=rld',\n      {\n        title: { endsWith: 'rld' },\n      },\n    ],\n  ]\n\n  for (const [query, expected] of cases) {\n    await t.test(query, () => {\n      assert.deepEqual(parseWhere(query), expected)\n    })\n  }\n})\n"
  },
  {
    "path": "src/parse-where.ts",
    "content": "import { setProperty } from 'dot-prop'\nimport type { JsonObject } from 'type-fest'\n\nimport { isWhereOperator, type WhereOperator } from './where-operators.ts'\n\nfunction splitKey(key: string): { path: string; op: WhereOperator | null } {\n  const colonIdx = key.lastIndexOf(':')\n  if (colonIdx !== -1) {\n    const path = key.slice(0, colonIdx)\n    const op = key.slice(colonIdx + 1)\n    if (!op) {\n      return { path: key, op: 'eq' }\n    }\n\n    return isWhereOperator(op) ? { path, op } : { path, op: null }\n  }\n\n  // Compatibility with v0.17 operator style (e.g. _lt, _gt)\n  const underscoreMatch = key.match(/^(.*)_([a-z]+)$/)\n  if (underscoreMatch) {\n    const path = underscoreMatch[1]\n    const op = underscoreMatch[2]\n    if (path && isWhereOperator(op)) {\n      return { path, op }\n    }\n  }\n\n  return { path: key, op: 'eq' }\n}\n\nfunction setPathOp(root: JsonObject, path: string, op: WhereOperator, value: string): void {\n  const fullPath = `${path}.${op}`\n  if (op === 'in') {\n    setProperty(\n      root,\n      fullPath,\n      value.split(',').map((part) => coerceValue(part.trim())),\n    )\n    return\n  }\n\n  setProperty(root, fullPath, coerceValue(value))\n}\n\nfunction coerceValue(value: string): string | number | boolean | null {\n  if (value === 'true') return true\n  if (value === 'false') return false\n  if (value === 'null') return null\n\n  if (value.trim() === '') return value\n\n  const num = Number(value)\n  if (Number.isFinite(num)) return num\n\n  return value\n}\n\nexport function parseWhere(query: string): JsonObject {\n  const out: JsonObject = {}\n  const params = new URLSearchParams(query)\n\n  for (const [rawKey, rawValue] of params.entries()) {\n    const { path, op } = splitKey(rawKey)\n    if (op === null) continue\n    setPathOp(out, path, op, rawValue)\n  }\n\n  return out\n}\n"
  },
  {
    "path": "src/random-id.ts",
    "content": "import { randomBytes } from 'node:crypto'\n\nexport function randomId(): string {\n  return randomBytes(2).toString('hex')\n}\n"
  },
  {
    "path": "src/service.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test, { beforeEach } from 'node:test'\n\nimport { Low, Memory } from 'lowdb'\nimport type { JsonObject } from 'type-fest'\n\nimport type { Data } from './service.ts'\nimport { Service } from './service.ts'\n\nconst defaultData = { posts: [], comments: [], object: {} }\nconst adapter = new Memory<Data>()\nconst db = new Low<Data>(adapter, defaultData)\nconst service = new Service(db)\n\nconst POSTS = 'posts'\nconst COMMENTS = 'comments'\nconst OBJECT = 'object'\n\nconst UNKNOWN_RESOURCE = 'xxx'\nconst UNKNOWN_ID = 'xxx'\n\nconst post1 = {\n  id: '1',\n  title: 'a',\n  views: 100,\n  published: true,\n  author: { name: 'foo' },\n  tags: ['foo', 'bar'],\n}\nconst post2 = {\n  id: '2',\n  title: 'b',\n  views: 200,\n  published: false,\n  author: { name: 'bar' },\n  tags: ['bar'],\n}\nconst post3 = {\n  id: '3',\n  title: 'c',\n  views: 300,\n  published: false,\n  author: { name: 'baz' },\n  tags: ['foo'],\n}\nconst comment1 = { id: '1', title: 'a', postId: '1' }\nconst obj = {\n  f1: 'foo',\n}\n\nbeforeEach(() => {\n  db.data = structuredClone({\n    posts: [post1, post2, post3],\n    comments: [comment1],\n    object: obj,\n  })\n})\n\nawait test('findById', () => {\n  const cases: [[string, string, { _embed?: string[] | string }], unknown][] = [\n    [[POSTS, '1', {}], db.data?.[POSTS]?.[0]],\n    [[POSTS, UNKNOWN_ID, {}], undefined],\n    [[POSTS, '1', { _embed: ['comments'] }], { ...post1, comments: [comment1] }],\n    [[COMMENTS, '1', { _embed: ['post'] }], { ...comment1, post: post1 }],\n    [[UNKNOWN_RESOURCE, '1', {}], undefined],\n  ]\n\n  for (const [[name, id, query], expected] of cases) {\n    assert.deepEqual(service.findById(name, id, query), expected)\n  }\n})\n\nawait test('find', async (t) => {\n  const whereFromPayload = JSON.parse('{\"author\":{\"name\":{\"eq\":\"bar\"}}}') as JsonObject\n\n  const cases: [{ where: JsonObject; sort?: string; page?: number; perPage?: number }, unknown][] =\n    [\n      [{ where: { title: { eq: 'b' } } }, [post2]],\n      [{ where: whereFromPayload }, [post2]],\n      [{ where: {}, sort: '-views' }, [post3, post2, post1]],\n      [\n        { where: {}, page: 2, perPage: 2 },\n        {\n          first: 1,\n          prev: 1,\n          next: null,\n          last: 2,\n          pages: 2,\n          items: 3,\n          data: [post3],\n        },\n      ],\n    ]\n\n  for (const [opts, expected] of cases) {\n    await t.test(JSON.stringify(opts), () => {\n      assert.deepEqual(service.find(POSTS, opts), expected)\n    })\n  }\n})\n\nawait test('create', async () => {\n  const post = { title: 'new post' }\n  const res = await service.create(POSTS, post)\n  assert.equal(res?.['title'], post.title)\n  assert.equal(typeof res?.['id'], 'string', 'id should be a string')\n\n  assert.equal(await service.create(UNKNOWN_RESOURCE, post), undefined)\n})\n\nawait test('update', async () => {\n  const obj = { f1: 'bar' }\n  const res = await service.update(OBJECT, obj)\n  assert.equal(res, obj)\n\n  assert.equal(\n    await service.update(UNKNOWN_RESOURCE, obj),\n    undefined,\n    'should ignore unknown resources',\n  )\n  assert.equal(await service.update(POSTS, {}), undefined, 'should ignore arrays')\n})\n\nawait test('patch', async () => {\n  const obj = { f2: 'bar' }\n  const res = await service.patch(OBJECT, obj)\n  assert.deepEqual(res, { f1: 'foo', ...obj })\n\n  assert.equal(\n    await service.patch(UNKNOWN_RESOURCE, obj),\n    undefined,\n    'should ignore unknown resources',\n  )\n  assert.equal(await service.patch(POSTS, {}), undefined, 'should ignore arrays')\n})\n\nawait test('updateById', async () => {\n  const post = { id: 'xxx', title: 'updated post' }\n  const res = await service.updateById(POSTS, post1.id, post)\n  assert.equal(res?.['id'], post1.id, 'id should not change')\n  assert.equal(res?.['title'], post.title)\n\n  assert.equal(await service.updateById(UNKNOWN_RESOURCE, post1.id, post), undefined)\n  assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined)\n})\n\nawait test('patchById', async () => {\n  const post = { id: 'xxx', title: 'updated post' }\n  const res = await service.patchById(POSTS, post1.id, post)\n  assert.notEqual(res, undefined)\n  assert.equal(res?.['id'], post1.id)\n  assert.equal(res?.['title'], post.title)\n\n  assert.equal(await service.patchById(UNKNOWN_RESOURCE, post1.id, post), undefined)\n  assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined)\n})\n\nawait test('destroy', async (t) => {\n  await t.test('nullifies foreign keys', async () => {\n    const prevLength = Number(db.data?.[POSTS]?.length) || 0\n    await service.destroyById(POSTS, post1.id)\n    assert.equal(db.data?.[POSTS]?.length, prevLength - 1)\n    assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }])\n  })\n\n  await t.test('deletes dependent resources', async () => {\n    const prevLength = Number(db.data?.[POSTS]?.length) || 0\n    await service.destroyById(POSTS, post1.id, [COMMENTS])\n    assert.equal(db.data[POSTS].length, prevLength - 1)\n    assert.equal(db.data[COMMENTS].length, 0)\n  })\n\n  await t.test('ignores unknown resources', async () => {\n    assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined)\n    assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined)\n  })\n})\n"
  },
  {
    "path": "src/service.ts",
    "content": "import inflection from 'inflection'\nimport { Low } from 'lowdb'\nimport sortOn from 'sort-on'\nimport type { JsonObject } from 'type-fest'\n\nimport { matchesWhere } from './matches-where.ts'\nimport { paginate, type PaginationResult } from './paginate.ts'\nimport { randomId } from './random-id.ts'\nexport type Item = Record<string, unknown>\n\nexport type Data = Record<string, Item[] | Item>\n\nexport function isItem(obj: unknown): obj is Item {\n  return typeof obj === 'object' && obj !== null && !Array.isArray(obj)\n}\n\nexport type PaginatedItems = PaginationResult<Item>\n\nfunction ensureArray(arg: string | string[] = []): string[] {\n  return Array.isArray(arg) ? arg : [arg]\n}\n\nfunction embed(db: Low<Data>, name: string, item: Item, related: string): Item {\n  if (inflection.singularize(related) === related) {\n    const relatedData = db.data[inflection.pluralize(related)] as Item[]\n    if (!relatedData) {\n      return item\n    }\n    const foreignKey = `${related}Id`\n    const relatedItem = relatedData.find((relatedItem: Item) => {\n      return relatedItem['id'] === item[foreignKey]\n    })\n    return { ...item, [related]: relatedItem }\n  }\n  const relatedData: Item[] = db.data[related] as Item[]\n\n  if (!relatedData) {\n    return item\n  }\n\n  const foreignKey = `${inflection.singularize(name)}Id`\n  const relatedItems = relatedData.filter(\n    (relatedItem: Item) => relatedItem[foreignKey] === item['id'],\n  )\n\n  return { ...item, [related]: relatedItems }\n}\n\nfunction nullifyForeignKey(db: Low<Data>, name: string, id: string) {\n  const foreignKey = `${inflection.singularize(name)}Id`\n\n  Object.entries(db.data).forEach(([key, items]) => {\n    // Skip\n    if (key === name) return\n\n    // Nullify\n    if (Array.isArray(items)) {\n      items.forEach((item) => {\n        if (item[foreignKey] === id) {\n          item[foreignKey] = null\n        }\n      })\n    }\n  })\n}\n\nfunction deleteDependents(db: Low<Data>, name: string, dependents: string[]) {\n  const foreignKey = `${inflection.singularize(name)}Id`\n\n  Object.entries(db.data).forEach(([key, items]) => {\n    // Skip\n    if (key === name || !dependents.includes(key)) return\n\n    // Delete if foreign key is null\n    if (Array.isArray(items)) {\n      db.data[key] = items.filter((item) => item[foreignKey] !== null)\n    }\n  })\n}\n\nexport class Service {\n  #db: Low<Data>\n\n  constructor(db: Low<Data>) {\n    this.#db = db\n  }\n\n  #get(name: string): Item[] | Item | undefined {\n    return this.#db.data[name]\n  }\n\n  has(name: string): boolean {\n    return Object.prototype.hasOwnProperty.call(this.#db?.data, name)\n  }\n\n  findById(name: string, id: string, query: { _embed?: string[] | string }): Item | undefined {\n    const value = this.#get(name)\n\n    if (Array.isArray(value)) {\n      let item = value.find((item) => item['id'] === id)\n      ensureArray(query._embed).forEach((related) => {\n        if (item !== undefined) item = embed(this.#db, name, item, related)\n      })\n      return item\n    }\n\n    return\n  }\n\n  find(\n    name: string,\n    opts: {\n      where: JsonObject\n      sort?: string\n      page?: number\n      perPage?: number\n      embed?: string | string[]\n    },\n  ): Item[] | PaginatedItems | Item | undefined {\n    const items = this.#get(name)\n\n    if (!Array.isArray(items)) {\n      return items\n    }\n\n    let results = items\n\n    // Include\n    ensureArray(opts.embed).forEach((related) => {\n      results = results.map((item) => embed(this.#db, name, item, related))\n    })\n\n    results = results.filter((item) => matchesWhere(item as JsonObject, opts.where))\n    if (opts.sort) {\n      results = sortOn(results, opts.sort.split(','))\n    }\n\n    if (opts.page !== undefined) {\n      return paginate(results, opts.page, opts.perPage ?? 10)\n    }\n\n    return results\n  }\n\n  async create(name: string, data: Omit<Item, 'id'> = {}): Promise<Item | undefined> {\n    const items = this.#get(name)\n    if (items === undefined || !Array.isArray(items)) return\n\n    const item = { id: randomId(), ...data }\n    items.push(item)\n\n    await this.#db.write()\n    return item\n  }\n\n  async #updateOrPatch(name: string, body: Item = {}, isPatch: boolean): Promise<Item | undefined> {\n    const item = this.#get(name)\n    if (item === undefined || Array.isArray(item)) return\n\n    const nextItem = (this.#db.data[name] = isPatch ? { ...item, ...body } : body)\n\n    await this.#db.write()\n    return nextItem\n  }\n\n  async #updateOrPatchById(\n    name: string,\n    id: string,\n    body: Item = {},\n    isPatch: boolean,\n  ): Promise<Item | undefined> {\n    const items = this.#get(name)\n    if (items === undefined || !Array.isArray(items)) return\n\n    const item = items.find((item) => item['id'] === id)\n    if (!item) return\n\n    const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id }\n    const index = items.indexOf(item)\n    items.splice(index, 1, nextItem)\n\n    await this.#db.write()\n    return nextItem\n  }\n\n  async update(name: string, body: Item = {}): Promise<Item | undefined> {\n    return this.#updateOrPatch(name, body, false)\n  }\n\n  async patch(name: string, body: Item = {}): Promise<Item | undefined> {\n    return this.#updateOrPatch(name, body, true)\n  }\n\n  async updateById(name: string, id: string, body: Item = {}): Promise<Item | undefined> {\n    return this.#updateOrPatchById(name, id, body, false)\n  }\n\n  async patchById(name: string, id: string, body: Item = {}): Promise<Item | undefined> {\n    return this.#updateOrPatchById(name, id, body, true)\n  }\n\n  async destroyById(\n    name: string,\n    id: string,\n    dependent?: string | string[],\n  ): Promise<Item | undefined> {\n    const items = this.#get(name)\n    if (items === undefined || !Array.isArray(items)) return\n\n    const item = items.find((item) => item['id'] === id)\n    if (item === undefined) return\n    const index = items.indexOf(item)\n    items.splice(index, 1)\n\n    nullifyForeignKey(this.#db, name, id)\n    const dependents = ensureArray(dependent)\n    deleteDependents(this.#db, name, dependents)\n\n    await this.#db.write()\n    return item\n  }\n}\n"
  },
  {
    "path": "src/where-operators.ts",
    "content": "export const WHERE_OPERATORS = [\n  'lt',\n  'lte',\n  'gt',\n  'gte',\n  'eq',\n  'ne',\n  'in',\n  'contains',\n  'startsWith',\n  'endsWith',\n] as const\n\nexport type WhereOperator = (typeof WHERE_OPERATORS)[number]\n\nexport function isWhereOperator(value: string): value is WhereOperator {\n  return (WHERE_OPERATORS as readonly string[]).includes(value)\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"exclude\": [\"src/**/*.test.ts\"],\n  \"compilerOptions\": {\n    \"outDir\": \"./lib\",\n    \"target\": \"esnext\",\n    \"module\": \"nodenext\",\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"allowImportingTsExtensions\": true,\n    \"rewriteRelativeImportExtensions\": true,\n    \"erasableSyntaxOnly\": true,\n    \"verbatimModuleSyntax\": true\n  }\n}\n"
  },
  {
    "path": "views/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>JSON Server</title>\n    <style>\n      :root {\n        color-scheme: light dark;\n        --fg: light-dark(#111, #e8e8e8);\n        --bg: light-dark(#fff, #111214);\n        --muted: light-dark(#999, #9aa0a6);\n        --line: light-dark(#e5e5e5, #2c2f34);\n        --accent: light-dark(#6366f1, #8b90ff);\n        --chip-bg: light-dark(#111, #f5f5f5);\n        --chip-fg: light-dark(#fff, #111214);\n      }\n      body {\n        margin: 40px auto;\n        max-width: 400px;\n        padding: 0 24px;\n        font-family:\n          ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n        background-color: var(--bg);\n        color: var(--fg);\n        line-height: 1.5;\n        font-size: 14px;\n        transition:\n          background-color 0.2s ease,\n          color 0.2s ease,\n          border-color 0.2s ease;\n      }\n      a {\n        color: inherit;\n      }\n      header {\n        margin-bottom: 40px;\n        color: var(--muted);\n      }\n      .topbar {\n        display: flex;\n        justify-content: space-between;\n        align-items: baseline;\n        margin-bottom: 12px;\n        padding-top: 12px;\n        border-top: 1px solid var(--line);\n      }\n      .topbar a {\n        text-decoration: none;\n        transition: color 0.1s ease;\n      }\n      .topbar a:first-child {\n        color: var(--fg);\n      }\n      .topbar a:hover {\n        color: var(--accent);\n      }\n      .section-title {\n        margin-bottom: 8px;\n        color: var(--muted);\n      }\n      .list {\n        display: flex;\n        flex-direction: column;\n        gap: 4px;\n      }\n      .list a {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        padding: 8px 0;\n        text-decoration: none;\n        transition: color 0.1s ease;\n      }\n      .list a:hover {\n        color: var(--accent);\n      }\n      .meta {\n        color: var(--muted);\n        font-size: 13px;\n      }\n      .empty {\n        color: var(--muted);\n      }\n      footer {\n        margin-top: 48px;\n        padding-top: 12px;\n        border-top: 1px solid var(--line);\n        color: var(--muted);\n        font-size: 13px;\n      }\n      .heart {\n        position: fixed;\n        bottom: 20px;\n        right: 24px;\n        display: inline-flex;\n        align-items: center;\n        justify-content: center;\n        width: 32px;\n        height: 32px;\n        border-radius: 50%;\n        color: var(--chip-fg);\n        background-color: var(--chip-bg);\n        text-decoration: none;\n        transition: transform 0.15s ease;\n      }\n      .heart:hover {\n        transform: scale(1.1);\n      }\n    </style>\n  </head>\n\n  <body>\n    <% const resources = Object.entries(it.data ?? {}); %>\n\n    <header>\n      <div class=\"topbar\">\n        <a href=\"https://github.com/typicode/json-server\" target=\"_blank\">json-server</a>\n        <a href=\"https://github.com/typicode/json-server\" target=\"_blank\">README</a>\n      </div>\n      <div class=\"intro\">Available REST resources from db.json.</div>\n    </header>\n\n    <div class=\"section-title\">Resources</div>\n\n    <div class=\"list\">\n      <% if (resources.length === 0) { %>\n      <span class=\"empty\">No resources in db.json.</span>\n      <% } else { %> <% resources.forEach(function([name, value]) { const isCollection =\n      Array.isArray(value); %>\n      <a href=\"/<%= name %>\">\n        <span>/<%= name %></span>\n        <span class=\"meta\">\n          <% if (isCollection) { %> <%= value.length %> items <% } else { %> object <% } %>\n        </span>\n      </a>\n      <% }) %> <% } %>\n    </div>\n\n    <footer>\n      <span>To replace this page, create public/index.html.</span>\n    </footer>\n\n    <a href=\"https://github.com/sponsors/typicode\" target=\"_blank\" class=\"heart\">❤</a>\n\n  </body>\n</html>\n"
  }
]