@kollors/deep-json-server 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,7 +110,7 @@ export default {
110
110
  schema: { $info: { title: 'Movie API', version: '1.0.0' } },
111
111
  },
112
112
  files: {
113
- data: [{ content: new Uint8Array([1, 2, 3]), id: 'file-1', mimeType: 'application/octet-stream', name: 'example.bin' }],
113
+ data: [{ content: new Uint8Array([1, 2, 3]), directory: 'examples', mimeType: 'application/octet-stream', name: 'example.bin' }],
114
114
  },
115
115
  };
116
116
  ```
@@ -352,33 +352,62 @@ Add `files.directory` and `files.metadata` to the server config, then pass `--fi
352
352
  deep-json-server --files server.config.js
353
353
  ```
354
354
 
355
- For temporary tests, use `files.data` instead. Each initial record contains `id`, `name`, `mimeType`, and binary `content` as a `Uint8Array`; `size` and `url` are derived automatically. Uploaded files then remain in memory until the process exits.
355
+ For temporary tests, use `files.data` instead. Each initial record contains `name`, `mimeType`, binary `content` as a `Uint8Array`, and an optional `directory`. Uploaded files then remain in memory until the process exits.
356
356
 
357
- Upload one file as the request body. Both headers are required: `Content-Name` contains the relative logical name encoded with `encodeURIComponent`, and `Content-Type` contains the file MIME type:
357
+ Upload one file directly as the request body. `Content-Name` contains the URI-encoded file name, `Content-Type` contains its MIME type, and the optional `Content-Directory` contains the URI-encoded relative directory:
358
358
 
359
359
  ```http
360
- POST /_files
361
- Content-Name: posters%2Fshadows-of-ardenia.jpg
360
+ POST /_files/storage
361
+ Content-Name: shadows-of-ardenia.jpg
362
+ Content-Directory: posters
362
363
  Content-Type: image/jpeg
363
364
 
364
365
  <binary body>
365
366
  ```
366
367
 
367
- A successful upload returns status `201`, metadata, and a stable URL:
368
+ A new file returns status `201` and its computed metadata:
368
369
 
369
370
  ```json
370
371
  {
371
- "id": "generated-id",
372
+ "directory": "posters",
373
+ "downloadUrl": "/_files/download/posters/shadows-of-ardenia.jpg",
374
+ "metadataUrl": "/_files/metadata/posters/shadows-of-ardenia.jpg",
372
375
  "mimeType": "image/jpeg",
373
- "name": "posters/shadows-of-ardenia.jpg",
376
+ "name": "shadows-of-ardenia.jpg",
374
377
  "size": 182340,
375
- "url": "/_files/generated-id"
378
+ "url": "/_files/storage/posters/shadows-of-ardenia.jpg"
376
379
  }
377
380
  ```
378
381
 
379
- `GET /_files/:id` returns the original bytes with status `200`. `DELETE /_files/:id` removes both the binary contents and metadata, and returns the deleted metadata with status `200`. The returned `url` is relative to the mock-server origin. The server creates the configured directories automatically, stores binary contents under generated IDs without relying on the original file name, and keeps the logical names and other metadata in `files.metadata`. The metadata file may be absent initially and is created on the first upload. Do not edit it while the server is running.
382
+ The combination of `directory` and `name` identifies a file. Uploading to an existing path returns `409`. Pass `Content-Override: true` to replace it; a successful replacement returns `200`. The server supports these file routes:
380
383
 
381
- The upload is raw binary rather than `multipart/form-data`, so `XMLHttpRequest.upload.onprogress` can report progress while the browser sends a `File` directly with `xhr.send(file)`. The default maximum size is 100 MiB and can be changed through `server.maxFileSize`. A missing or unsafe `Content-Name` returns `400`, an exceeded limit returns `413`, and a missing, malformed, or Fastify-unsupported `Content-Type` returns `400` or `415`, depending on which validation stage rejects it.
384
+ ```text
385
+ POST /_files/storage Upload or replace a file
386
+ GET /_files/storage/* Return file contents inline
387
+ PATCH /_files/storage/* Rename or move a file
388
+ DELETE /_files/storage/* Delete a file
389
+
390
+ GET /_files/metadata/* Return file metadata as JSON
391
+ GET /_files/download/* Download a file as an attachment
392
+ ```
393
+
394
+ Rename, move, or perform both operations with a JSON body. At least one field is required:
395
+
396
+ ```http
397
+ PATCH /_files/storage/posters/shadows-of-ardenia.jpg
398
+ Content-Type: application/json
399
+
400
+ {
401
+ "directory": "archive/posters",
402
+ "name": "ardenia-shadows.jpg"
403
+ }
404
+ ```
405
+
406
+ `PATCH` returns the updated metadata with status `200`; a conflicting destination returns `409`. `DELETE` returns `204` without a response body. A missing file returns `404` on every path-based operation. File paths in URLs are relative to `files.directory`, and all returned URLs are relative to the mock-server origin.
407
+
408
+ In disk mode, the binary is stored at `<files.directory>/<directory>/<name>`. The metadata file contains only `directory`, `mimeType`, and `name`; `size` is read from the actual file, while response URLs are computed. The server creates directories automatically. The metadata file may be absent initially and is created on the first upload. Do not edit stored files or metadata while the server is running. Metadata created by versions before this path-based API is not compatible with the new format.
409
+
410
+ The upload is raw binary rather than `multipart/form-data`, so `XMLHttpRequest.upload.onprogress` can report progress while the browser sends a `File` directly with `xhr.send(file)`. The default maximum size is 100 MiB and can be changed through `server.maxFileSize`. Missing or unsafe headers and paths return `400`, an exceeded limit returns `413`, and a missing, malformed, or Fastify-unsupported `Content-Type` returns `400` or `415`, depending on which validation stage rejects it.
382
411
 
383
412
  ## Database schema and OpenAPI generation
384
413
 
@@ -522,7 +551,7 @@ const memoryServer = await createServer({
522
551
  schema: { $info: { title: 'Movie API', version: '1.0.0' } },
523
552
  },
524
553
  files: {
525
- data: [{ content: new Uint8Array([1, 2, 3]), id: 'file-1', mimeType: 'application/octet-stream', name: 'example.bin' }],
554
+ data: [{ content: new Uint8Array([1, 2, 3]), directory: 'examples', mimeType: 'application/octet-stream', name: 'example.bin' }],
526
555
  },
527
556
  });
528
557
 
package/README.ru.md CHANGED
@@ -110,7 +110,7 @@ export default {
110
110
  schema: { $info: { title: 'API фильмов', version: '1.0.0' } },
111
111
  },
112
112
  files: {
113
- data: [{ content: new Uint8Array([1, 2, 3]), id: 'file-1', mimeType: 'application/octet-stream', name: 'example.bin' }],
113
+ data: [{ content: new Uint8Array([1, 2, 3]), directory: 'examples', mimeType: 'application/octet-stream', name: 'example.bin' }],
114
114
  },
115
115
  };
116
116
  ```
@@ -352,33 +352,62 @@ GET /countries/1?_embed=users
352
352
  deep-json-server --files server.config.js
353
353
  ```
354
354
 
355
- Для временных тестов вместо этого используйте `files.data`. Каждая начальная запись содержит `id`, `name`, `mimeType` и бинарное `content` в виде `Uint8Array`; `size` и `url` вычисляются автоматически. Загруженные файлы в таком режиме остаются в памяти до завершения процесса.
355
+ Для временных тестов вместо этого используйте `files.data`. Каждая начальная запись содержит `name`, `mimeType`, бинарное `content` в виде `Uint8Array` и необязательный `directory`. Загруженные файлы в таком режиме остаются в памяти до завершения процесса.
356
356
 
357
- Один файл отправляется непосредственно в теле запроса. Оба заголовка обязательны: `Content-Name` содержит относительное логическое имя, закодированное через `encodeURIComponent`, а `Content-Type` — MIME-тип файла:
357
+ Один файл отправляется непосредственно в теле запроса. `Content-Name` содержит URI-кодированное имя файла, `Content-Type` его MIME-тип, а необязательный `Content-Directory` — URI-кодированный относительный путь к директории:
358
358
 
359
359
  ```http
360
- POST /_files
361
- Content-Name: posters%2Fshadows-of-ardenia.jpg
360
+ POST /_files/storage
361
+ Content-Name: shadows-of-ardenia.jpg
362
+ Content-Directory: posters
362
363
  Content-Type: image/jpeg
363
364
 
364
365
  <binary body>
365
366
  ```
366
367
 
367
- Успешная загрузка возвращает статус `201`, метаданные и постоянный URL:
368
+ Новый файл возвращает статус `201` и вычисленные метаданные:
368
369
 
369
370
  ```json
370
371
  {
371
- "id": "generated-id",
372
+ "directory": "posters",
373
+ "downloadUrl": "/_files/download/posters/shadows-of-ardenia.jpg",
374
+ "metadataUrl": "/_files/metadata/posters/shadows-of-ardenia.jpg",
372
375
  "mimeType": "image/jpeg",
373
- "name": "posters/shadows-of-ardenia.jpg",
376
+ "name": "shadows-of-ardenia.jpg",
374
377
  "size": 182340,
375
- "url": "/_files/generated-id"
378
+ "url": "/_files/storage/posters/shadows-of-ardenia.jpg"
376
379
  }
377
380
  ```
378
381
 
379
- `GET /_files/:id` возвращает исходные байты со статусом `200`, а `DELETE /_files/:id` удаляет бинарное содержимое вместе с метаданными и возвращает удалённые метаданные со статусом `200`. Возвращаемый `url` задаётся относительно адреса mock-сервера. Сервер автоматически создаёт настроенные директории, хранит бинарное содержимое под сгенерированными ID без привязки к исходному имени и записывает логические имена и остальные метаданные в `files.metadata`. Изначально файл метаданных может отсутствовать: он создаётся при первой загрузке. Не редактируйте его во время работы сервера.
382
+ Сочетание `directory` и `name` идентифицирует файл. Повторная загрузка по существующему пути возвращает `409`. Чтобы заменить файл, передайте `Content-Override: true`; успешная перезапись возвращает `200`. Сервер поддерживает следующие файловые маршруты:
380
383
 
381
- Используется бинарное тело запроса, а не `multipart/form-data`, поэтому `XMLHttpRequest.upload.onprogress` может показывать прогресс при непосредственной отправке `File` через `xhr.send(file)`. Максимальный размер по умолчанию равен 100 МиБ и настраивается через `server.maxFileSize`. Отсутствующий или небезопасный `Content-Name` возвращает `400`, превышение лимита — `413`, а отсутствующий, некорректный или не поддерживаемый Fastify `Content-Type` — `400` либо `415` в зависимости от этапа проверки.
384
+ ```text
385
+ POST /_files/storage Загрузка или перезапись файла
386
+ GET /_files/storage/* Просмотр содержимого файла
387
+ PATCH /_files/storage/* Переименование или перемещение файла
388
+ DELETE /_files/storage/* Удаление файла
389
+
390
+ GET /_files/metadata/* Получение метаданных в JSON
391
+ GET /_files/download/* Скачивание файла
392
+ ```
393
+
394
+ Для переименования, перемещения либо обеих операций отправьте JSON-объект. Нужно указать хотя бы одно поле:
395
+
396
+ ```http
397
+ PATCH /_files/storage/posters/shadows-of-ardenia.jpg
398
+ Content-Type: application/json
399
+
400
+ {
401
+ "directory": "archive/posters",
402
+ "name": "ardenia-shadows.jpg"
403
+ }
404
+ ```
405
+
406
+ `PATCH` возвращает обновлённые метаданные со статусом `200`; занятый конечный путь возвращает `409`. `DELETE` отвечает статусом `204` без тела. Если файл не найден, любая операция по пути возвращает `404`. Пути в URL задаются относительно `files.directory`, а все возвращаемые URL — относительно адреса mock-сервера.
407
+
408
+ При хранении на диске бинарный файл находится по пути `<files.directory>/<directory>/<name>`. Файл метаданных содержит только `directory`, `mimeType` и `name`; `size` считывается у фактического файла, а URL вычисляются. Сервер автоматически создаёт директории. Изначально файл метаданных может отсутствовать: он создаётся при первой загрузке. Не редактируйте сохранённые файлы или метаданные во время работы сервера. Метаданные, созданные версиями до перехода на адресацию по пути, несовместимы с новым форматом.
409
+
410
+ Используется бинарное тело запроса, а не `multipart/form-data`, поэтому `XMLHttpRequest.upload.onprogress` может показывать прогресс при непосредственной отправке `File` через `xhr.send(file)`. Максимальный размер по умолчанию равен 100 МиБ и настраивается через `server.maxFileSize`. Отсутствующие или небезопасные заголовки и пути возвращают `400`, превышение лимита — `413`, а отсутствующий, некорректный или не поддерживаемый Fastify `Content-Type` — `400` либо `415` в зависимости от этапа проверки.
382
411
 
383
412
  ## Схема базы данных и генерация OpenAPI
384
413
 
@@ -522,7 +551,7 @@ const memoryServer = await createServer({
522
551
  schema: { $info: { title: 'API фильмов', version: '1.0.0' } },
523
552
  },
524
553
  files: {
525
- data: [{ content: new Uint8Array([1, 2, 3]), id: 'file-1', mimeType: 'application/octet-stream', name: 'example.bin' }],
554
+ data: [{ content: new Uint8Array([1, 2, 3]), directory: 'examples', mimeType: 'application/octet-stream', name: 'example.bin' }],
526
555
  },
527
556
  });
528
557
 
package/index.js CHANGED
@@ -2,6 +2,11 @@
2
2
  /** @typedef {import('./src/config.js').DatabaseConfig} DatabaseConfig */
3
3
  /** @typedef {import('./src/config.js').FilesConfig} FilesConfig */
4
4
  /** @typedef {import('./src/config.js').MemoryFile} MemoryFile */
5
+ /** @typedef {import('./src/config.js').OpenapiConfig} OpenapiConfig */
6
+ /** @typedef {import('./src/config.js').ServerConfig} ServerConfig */
7
+ /** @typedef {import('./src/files.js').FileMetadata} FileMetadata */
8
+ /** @typedef {import('./src/files.js').FileUpdate} FileUpdate */
5
9
  /** @typedef {import('./src/server.js').OpenapiDocument} OpenapiDocument */
10
+ /** @typedef {import('./src/server.js').ServerFacade} ServerFacade */
6
11
 
7
12
  export { createServer } from './src/server.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kollors/deep-json-server",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "JSON mock server with deep filters and recursive relationship embedding",
5
5
  "type": "module",
6
6
  "types": "./types/index.d.ts",
@@ -52,11 +52,18 @@
52
52
  },
53
53
  "keywords": [
54
54
  "api",
55
+ "crud",
55
56
  "deep-filter",
57
+ "file-upload",
56
58
  "json",
59
+ "json-server",
57
60
  "mock",
61
+ "mock-api",
62
+ "mock-server",
63
+ "openapi",
58
64
  "relations",
59
- "rest"
65
+ "rest",
66
+ "rest-api"
60
67
  ],
61
68
  "author": "kollors",
62
69
  "license": "MIT",
package/src/cli.js CHANGED
@@ -73,10 +73,10 @@ export async function runCli(args = process.argv.slice(2), services = { createSe
73
73
  validateModeConfig(config, { files, openapiMode });
74
74
 
75
75
  const runtimeConfig = { ...config, server: { ...config.server, host, port } };
76
- const server = await services.createServer(runtimeConfig, { files });
76
+ const serverFacade = await services.createServer(runtimeConfig, { files });
77
77
 
78
78
  if (openapiMode !== 'none') {
79
- await server.openapi();
79
+ await serverFacade.openapi();
80
80
  process.stdout.write(`OpenAPI-схема сохранена в ${config.openapi.path}\n`);
81
81
  }
82
82
 
@@ -84,7 +84,7 @@ export async function runCli(args = process.argv.slice(2), services = { createSe
84
84
  return;
85
85
  }
86
86
 
87
- const fastify = server.fastify();
87
+ const fastify = serverFacade.fastify();
88
88
 
89
89
  await fastify.listen({ host, port });
90
90
  fastify.log.info({ database: 'path' in config.database ? config.database.path : 'memory' }, 'Deep JSON Server запущен');
package/src/config.js CHANGED
@@ -12,15 +12,18 @@ let configImportIndex = 0;
12
12
  /** @typedef {Record<string, Array<Record<string, unknown>>>} DatabaseData */
13
13
  /** @typedef {Record<string, unknown>} DatabaseSchema */
14
14
  /** @typedef {{ data: DatabaseData, path?: never, schema?: DatabaseSchema | string } | { data?: never, path: string, schema?: DatabaseSchema | string }} DatabaseConfig */
15
- /** @typedef {{ content: Uint8Array, id: string, mimeType: string, name: string }} MemoryFile */
15
+ /** @typedef {{ content: Uint8Array, directory?: string, mimeType: string, name: string }} MemoryFile */
16
16
  /** @typedef {{ data: MemoryFile[], directory?: never, metadata?: never } | { data?: never, directory: string, metadata: string }} FilesConfig */
17
+ /** @typedef {{ path?: string }} OpenapiConfig */
18
+ /** @typedef {{ host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number }} ServerConfig */
17
19
  /**
18
20
  * @typedef {object} DeepJsonServerConfig
19
21
  * @property {DatabaseConfig} database Database source and optional schema.
20
22
  * @property {FilesConfig} [files] Binary-file storage.
21
- * @property {{ path?: string }} [openapi] Generated OpenAPI file.
22
- * @property {{ host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number }} [server] Runtime settings.
23
+ * @property {OpenapiConfig} [openapi] Generated OpenAPI file.
24
+ * @property {ServerConfig} [server] Runtime settings.
23
25
  */
26
+ /** @typedef {{ database: DatabaseConfig, files?: FilesConfig, openapi: OpenapiConfig, server: ServerConfig }} NormalizedServerConfig */
24
27
 
25
28
  const assertKnownKeys = (value, keys, path) => {
26
29
  const unknownKey = Object.keys(value).find((key) => !keys.has(key));
@@ -120,9 +123,10 @@ const normalizeFiles = (value, directoryPath) => {
120
123
  };
121
124
 
122
125
  /**
126
+ * Validates configuration and resolves relative paths.
123
127
  * @param {DeepJsonServerConfig} config Server configuration.
124
128
  * @param {string} [directoryPath] Base directory for relative paths.
125
- * @returns {{ database: DatabaseConfig, files?: FilesConfig, openapi: { path?: string }, server: { host?: string, logger?: boolean | Record<string, unknown>, maxFileSize?: number, maxPageSize?: number, port?: number } }} Normalized configuration.
129
+ * @returns {NormalizedServerConfig} Normalized configuration.
126
130
  */
127
131
  export const normalizeServerConfig = (config, directoryPath = '.') => {
128
132
  if (!isObject(config)) {
@@ -172,7 +176,11 @@ export const normalizeServerConfig = (config, directoryPath = '.') => {
172
176
  };
173
177
  };
174
178
 
175
- /** @param {string} configPath Configuration module path. */
179
+ /**
180
+ * Loads an ES module config and resolves paths from its directory.
181
+ * @param {string} configPath Configuration module path.
182
+ * @returns {Promise<NormalizedServerConfig>} Normalized configuration.
183
+ */
176
184
  export async function readServerConfig(configPath) {
177
185
  const resolvedConfigPath = resolve(getString(configPath, 'config', true));
178
186
  let config;
@@ -180,6 +188,7 @@ export async function readServerConfig(configPath) {
180
188
  try {
181
189
  const configUrl = pathToFileURL(resolvedConfigPath);
182
190
 
191
+ // Bypass the module cache so repeated reads use the latest config.
183
192
  configUrl.searchParams.set('deep-json-server-import', String(configImportIndex++));
184
193
  config = (await import(configUrl.href)).default;
185
194
  } catch (error) {
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_HOST = '127.0.0.1';
2
2
  export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
3
+ export const DEFAULT_MAX_PAGE_SIZE = 1000;
3
4
  export const DEFAULT_PAGE_SIZE = 10;
4
5
  export const DEFAULT_PORT = 4001;
5
- export const MAX_PAGE_SIZE = 1000;
package/src/database.js CHANGED
@@ -1,8 +1,16 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { readFile } from 'node:fs/promises';
3
2
  import { resolve } from 'node:path';
4
3
  import { JSONFilePreset } from 'lowdb/node';
5
- import { createHttpError, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
4
+ import { createHttpError, createSerialQueue, createUniqueId, isObject, isSafeKey, resolveDatabasePath } from './utils.js';
5
+
6
+ /** @typedef {{ data: import('./config.js').DatabaseData }} DatabaseContainer */
7
+ /**
8
+ * @typedef {object} DatabaseStore
9
+ * @property {DatabaseContainer} database Current database container.
10
+ * @property {string} [path] Resolved database file path.
11
+ * @property {() => Promise<import('./config.js').DatabaseData>} read Returns current data and reloads disk-backed sources.
12
+ * @property {<T>(operation: (database: DatabaseContainer) => T) => Promise<T>} update Runs a serialized update.
13
+ */
6
14
 
7
15
  const RESOURCE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
8
16
 
@@ -76,7 +84,7 @@ const createDiskDatabaseStore = async (databasePath) => {
76
84
  const resolvedDatabasePath = resolveDatabasePath(databasePath);
77
85
  const initialData = await readDatabaseFile(resolvedDatabasePath);
78
86
  const database = await JSONFilePreset(resolvedDatabasePath, initialData);
79
- let writeQueue = Promise.resolve();
87
+ const schedule = createSerialQueue();
80
88
 
81
89
  const read = async () => {
82
90
  database.data = await readDatabaseFile(resolvedDatabasePath);
@@ -84,8 +92,8 @@ const createDiskDatabaseStore = async (databasePath) => {
84
92
  return database.data;
85
93
  };
86
94
 
87
- const update = (operation) => {
88
- const pendingOperation = writeQueue.then(async () => {
95
+ const update = (operation) =>
96
+ schedule(async () => {
89
97
  await read();
90
98
 
91
99
  const result = operation(database);
@@ -96,21 +104,16 @@ const createDiskDatabaseStore = async (databasePath) => {
96
104
  return result;
97
105
  });
98
106
 
99
- writeQueue = pendingOperation.catch(() => undefined);
100
-
101
- return pendingOperation;
102
- };
103
-
104
107
  return { database, path: resolvedDatabasePath, read, update };
105
108
  };
106
109
 
107
110
  const createMemoryDatabaseStore = (sourceData) => {
108
111
  const database = { data: validateDatabase(structuredClone(sourceData)) };
109
- let updateQueue = Promise.resolve();
112
+ const schedule = createSerialQueue();
110
113
 
111
114
  const read = async () => database.data;
112
- const update = (operation) => {
113
- const pendingOperation = updateQueue.then(() => {
115
+ const update = (operation) =>
116
+ schedule(() => {
114
117
  const draft = { data: structuredClone(database.data) };
115
118
  const result = operation(draft);
116
119
 
@@ -120,17 +123,13 @@ const createMemoryDatabaseStore = (sourceData) => {
120
123
  return result;
121
124
  });
122
125
 
123
- updateQueue = pendingOperation.catch(() => undefined);
124
-
125
- return pendingOperation;
126
- };
127
-
128
126
  return { database, read, update };
129
127
  };
130
128
 
131
129
  /**
130
+ * Creates a disk- or memory-backed database with serialized updates.
132
131
  * @param {import('./config.js').DatabaseConfig} config Database source.
133
- * @returns {Promise<any>} Internal database store.
132
+ * @returns {Promise<DatabaseStore>} Database store.
134
133
  */
135
134
  export const createDatabaseStore = async (config) => ('data' in config ? createMemoryDatabaseStore(config.data) : createDiskDatabaseStore(config.path));
136
135
 
@@ -146,12 +145,4 @@ export const getCollection = (database, resource) => {
146
145
 
147
146
  export const findItem = (collection, id) => collection.find((item) => String(item.id) === String(id));
148
147
 
149
- export const createId = (collection) => {
150
- let id;
151
-
152
- do {
153
- id = randomBytes(8).toString('base64url');
154
- } while (findItem(collection, id) != null);
155
-
156
- return id;
157
- };
148
+ export const createId = (collection) => createUniqueId((id) => findItem(collection, id) != null);