@kollors/deep-json-server 1.0.0-alpha.3 → 1.0.0-alpha.5
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 +95 -20
- package/README.ru.md +95 -20
- package/dist/src/constants.d.ts +1 -1
- package/dist/src/constants.js +1 -1
- package/dist/src/engine.d.ts +2 -24
- package/dist/src/engine.js +12 -126
- package/dist/src/engine.js.map +1 -1
- package/dist/src/graphql/preflight.js +9 -0
- package/dist/src/graphql/preflight.js.map +1 -1
- package/dist/src/graphql/resolvers.d.ts +2 -1
- package/dist/src/graphql/resolvers.js +1 -1
- package/dist/src/graphql/resolvers.js.map +1 -1
- package/dist/src/graphql.js +19 -8
- package/dist/src/graphql.js.map +1 -1
- package/dist/src/model.d.ts +9 -3
- package/dist/src/model.js +58 -15
- package/dist/src/model.js.map +1 -1
- package/dist/src/mutations/write.d.ts +26 -0
- package/dist/src/mutations/write.js +274 -0
- package/dist/src/mutations/write.js.map +1 -0
- package/dist/src/openapi/document.js +61 -31
- package/dist/src/openapi/document.js.map +1 -1
- package/dist/src/records.d.ts +25 -0
- package/dist/src/records.js +48 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/rest/options.d.ts +7 -8
- package/dist/src/rest/options.js +42 -94
- package/dist/src/rest/options.js.map +1 -1
- package/dist/src/rest/projection.d.ts +5 -4
- package/dist/src/rest/projection.js +36 -19
- package/dist/src/rest/projection.js.map +1 -1
- package/dist/src/rest/routes.js +8 -8
- package/dist/src/rest/routes.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
A JSON-backed mock server with REST, GraphQL, nested queries, binary files and schema exports. Requires Node.js 22 or newer.
|
|
6
6
|
|
|
7
|
-
**1.0.0-alpha.
|
|
7
|
+
**1.0.0-alpha.5 is a prerelease.** REST queries use `scope=[fields, arguments?]` at every level. When upgrading from an earlier version, update query parameters using the examples below; upgrading from 0.x also requires the new model schema.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ A JSON-backed mock server with REST, GraphQL, nested queries, binary files and s
|
|
|
12
12
|
npm install @kollors/deep-json-server@alpha
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
To install a specific version, use `@1.0.0-alpha.
|
|
15
|
+
To install a specific version, use `@1.0.0-alpha.5`.
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
@@ -136,7 +136,7 @@ Examples: [database](examples/database.json), [model schema](examples/schema.jso
|
|
|
136
136
|
|
|
137
137
|
Explicit schemas are strict: undeclared fields and collections are rejected, except storage keys inferred from relations. Existing data is validated on startup. Generation uses the model definitions.
|
|
138
138
|
|
|
139
|
-
Schemaless REST generates an `id` and preserves arbitrary JSON fields. Filters and individual field selections use identifier-style names; other fields are returned through `scope
|
|
139
|
+
Schemaless REST generates an `id` and preserves arbitrary JSON fields. Filters and individual field selections use identifier-style names; other fields are returned through `scope=[{"*":true}]`. Fields with mixed value types can be read, but filtering, ordering and paging heterogeneous lists require an explicit schema.
|
|
140
140
|
|
|
141
141
|
### Fields
|
|
142
142
|
|
|
@@ -235,37 +235,112 @@ Root `where` selects records from the main collection. `where` inside a relation
|
|
|
235
235
|
|
|
236
236
|
The path parameter name follows the primary key. POST, PUT and PATCH accept a JSON record object. PUT replaces the record while retaining its key and server-managed fields. PATCH merges fields at the top level; supplied nested objects are replaced while preserving their read-only fields. Creation and replacement require all mandatory fields. Updates validate supplied values and the final record. Missing records return `404`; conflicts return `409`. DELETE returns the deleted record.
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
### Nested writes
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
Storage keys such as `genreIds: ["1"]` only set a relation. Relation fields also accept records to create or update:
|
|
241
|
+
|
|
242
|
+
```http
|
|
243
|
+
PATCH /movies/1
|
|
244
|
+
Content-Type: application/json
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
"actors": [
|
|
248
|
+
{
|
|
249
|
+
"userId": "1",
|
|
250
|
+
"genres": [
|
|
251
|
+
"1",
|
|
252
|
+
{ "id": "2", "name": "Updated genre" },
|
|
253
|
+
{ "name": "New genre" }
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
}
|
|
242
258
|
```
|
|
243
259
|
|
|
244
|
-
|
|
260
|
+
| Relation value | Behavior |
|
|
261
|
+
|---|---|
|
|
262
|
+
| A key, such as `"1"` | Link an existing record without changing it |
|
|
263
|
+
| An object with a primary key | PATCH updates supplied fields; PUT replaces the related record |
|
|
264
|
+
| An object without a primary key | Create a related record with defaults and a generated key |
|
|
265
|
+
|
|
266
|
+
The key name and type follow the target model. An object containing only a key still counts as an update: in PUT it must include the model's required fields. Replacement preserves primary keys, generated values and `readOnly` fields. In POST, nested objects with existing keys receive partial updates. A missing target is an error; creating a nested record without a key requires an autogenerated primary key.
|
|
267
|
+
|
|
268
|
+
A supplied list replaces the relation's membership. PATCH preserves omitted relations; PUT clears omitted writable links. `[]` clears a list and `null` clears a nullable single relation. Removing a link does not delete the related record. Required relations must remain populated.
|
|
269
|
+
|
|
270
|
+
Use either the relation field or its storage key in an object, for example `genres` or `genreIds`. Reverse relations update the target key. If a target path crosses an array and the server cannot identify one element to attach, provide the array with the intended keys explicitly. Protected keys cannot be changed.
|
|
271
|
+
|
|
272
|
+
All nested changes belong to the main record's transaction. A validation error, missing record or invalid response selection rolls back the entire operation. Updating a shared record affects every record linked to it.
|
|
273
|
+
|
|
274
|
+
GraphQL accepts typed objects in relation fields. To change only the links in a replace mutation, use storage keys such as `genreIds`. For example:
|
|
275
|
+
|
|
276
|
+
```graphql
|
|
277
|
+
mutation {
|
|
278
|
+
movieUpdate(id: "1", data: {
|
|
279
|
+
actors: [{
|
|
280
|
+
userId: "1"
|
|
281
|
+
genres: [{ id: "2", name: "Updated genre" }, { name: "New genre" }]
|
|
282
|
+
}]
|
|
283
|
+
}) {
|
|
284
|
+
actors { data { genres { data { id name } } } }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### REST query parameters
|
|
290
|
+
|
|
291
|
+
REST accepts one query parameter, `scope`, containing a JSON array `[fields, arguments?]`. The first object selects fields; the optional second object supplies `where`, `order` and `pager` for a list. The same format applies to the root query, embedded objects and relations.
|
|
292
|
+
|
|
293
|
+
Select users and their movies with independent ordering and pagination:
|
|
245
294
|
|
|
246
295
|
```js
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
296
|
+
const scope = [
|
|
297
|
+
{
|
|
298
|
+
id: true,
|
|
299
|
+
fullName: true,
|
|
300
|
+
movies: [
|
|
301
|
+
{ id: true, title: true },
|
|
302
|
+
{
|
|
303
|
+
order: [{ field: 'title', direction: 'ASC' }],
|
|
304
|
+
pager: { page: 1, pageSize: 5 },
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
where: { fullName: { contains: 'Мира' } },
|
|
310
|
+
order: [{ field: 'fullName', direction: 'ASC' }],
|
|
311
|
+
pager: { page: 1, pageSize: 20 },
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
const params = new URLSearchParams({ scope: JSON.stringify(scope) });
|
|
251
315
|
const response = await fetch(`/users?${params}`);
|
|
252
316
|
```
|
|
253
317
|
|
|
254
|
-
|
|
318
|
+
Select ordinary fields with `true` and objects or relations with their own scope arrays. Without arguments, the array contains only the fields object. `"*": true` includes own fields and stored keys, except `writeOnly` fields; select relations explicitly.
|
|
255
319
|
|
|
256
|
-
|
|
320
|
+
For example, select a movie's own fields, its actors' users and sorted genres:
|
|
257
321
|
|
|
258
322
|
```json
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
"
|
|
263
|
-
|
|
323
|
+
[
|
|
324
|
+
{
|
|
325
|
+
"*": true,
|
|
326
|
+
"actors": [
|
|
327
|
+
{
|
|
328
|
+
"user": [{ "id": true, "fullName": true }],
|
|
329
|
+
"genres": [
|
|
330
|
+
{ "*": true },
|
|
331
|
+
{ "order": [{ "field": "name", "direction": "ASC" }] }
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
]
|
|
264
335
|
}
|
|
265
|
-
|
|
336
|
+
]
|
|
266
337
|
```
|
|
267
338
|
|
|
268
|
-
|
|
339
|
+
Omitting `scope` returns own fields, as with `[{"*":true}]`. An empty selection `[{}]` returns an object without fields. Lists retain the `{ data, total }` response structure.
|
|
340
|
+
|
|
341
|
+
Arguments are available only on lists. Single-record queries and mutation responses can set arguments on their embedded lists. Parameters are validated even on empty data; an invalid response selection rolls back record changes. Invalid scopes return `400`. The JSON length limit is 10,000 characters; selection depth is limited to 32 levels.
|
|
342
|
+
|
|
343
|
+
OpenAPI remains at version 3.0.3. It cannot define a separate schema for each array position: the documentation describes the elements, and the server strictly validates their order.
|
|
269
344
|
|
|
270
345
|
### GraphQL
|
|
271
346
|
|
package/README.ru.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
JSON-сервер для имитации API: REST, GraphQL, вложенные запросы, бинарные файлы и экспорт схем. Требуется Node.js 22 или новее.
|
|
6
6
|
|
|
7
|
-
**1.0.0-alpha.
|
|
7
|
+
**1.0.0-alpha.5 — предварительная версия.** REST-запросы используют `scope=[поля, аргументы?]` на всех уровнях. При обновлении измените параметры запросов по примерам ниже; для перехода с 0.x также нужна новая схема моделей.
|
|
8
8
|
|
|
9
9
|
## Установка
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ JSON-сервер для имитации API: REST, GraphQL, вложенные
|
|
|
12
12
|
npm install @kollors/deep-json-server@alpha
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Для установки конкретной версии укажите `@1.0.0-alpha.
|
|
15
|
+
Для установки конкретной версии укажите `@1.0.0-alpha.5`.
|
|
16
16
|
|
|
17
17
|
## Быстрый старт
|
|
18
18
|
|
|
@@ -136,7 +136,7 @@ export default {
|
|
|
136
136
|
|
|
137
137
|
При явной схеме неизвестные поля и коллекции запрещены. Исключение — хранимые ключи, выведенные из связей. Исходная база проверяется при загрузке. Генерация использует описание моделей.
|
|
138
138
|
|
|
139
|
-
REST без схемы создаёт ключ `id` и сохраняет произвольные JSON-поля. Для фильтров и выбора отдельных полей используются имена в формате идентификаторов; остальные поля возвращаются через `scope
|
|
139
|
+
REST без схемы создаёт ключ `id` и сохраняет произвольные JSON-поля. Для фильтров и выбора отдельных полей используются имена в формате идентификаторов; остальные поля возвращаются через `scope=[{"*":true}]`. Поля с разными типами значений можно читать, но фильтрация, сортировка и пагинация неоднородных списков требуют явной схемы.
|
|
140
140
|
|
|
141
141
|
### Поля
|
|
142
142
|
|
|
@@ -235,37 +235,112 @@ REST без схемы создаёт ключ `id` и сохраняет про
|
|
|
235
235
|
|
|
236
236
|
Имя параметра пути соответствует первичному ключу. POST, PUT и PATCH принимают JSON-объект записи. PUT заменяет запись с сохранением ключа и серверных полей. PATCH объединяет поля на верхнем уровне; переданные вложенные объекты заменяются с сохранением их полей `readOnly`. Создание и замена требуют всех обязательных полей. При обновлении проверяются переданные значения и итоговая запись. Отсутствующая запись — `404`, конфликт — `409`. DELETE возвращает удалённую запись.
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
### Вложенная запись
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
Поля ключей, например `genreIds: ["1"]`, только задают связь. В поля связей можно передавать записи для создания или обновления:
|
|
241
|
+
|
|
242
|
+
```http
|
|
243
|
+
PATCH /movies/1
|
|
244
|
+
Content-Type: application/json
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
"actors": [
|
|
248
|
+
{
|
|
249
|
+
"userId": "1",
|
|
250
|
+
"genres": [
|
|
251
|
+
"1",
|
|
252
|
+
{ "id": "2", "name": "Обновлённый жанр" },
|
|
253
|
+
{ "name": "Новый жанр" }
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
}
|
|
242
258
|
```
|
|
243
259
|
|
|
244
|
-
|
|
260
|
+
| Значение в связи | Действие |
|
|
261
|
+
|---|---|
|
|
262
|
+
| Ключ, например `"1"` | Связать существующую запись без её изменения |
|
|
263
|
+
| Объект с первичным ключом | PATCH обновляет переданные поля, PUT заменяет связанную запись |
|
|
264
|
+
| Объект без первичного ключа | Создать запись со значениями по умолчанию и сгенерированным ключом |
|
|
265
|
+
|
|
266
|
+
Имя и тип ключа берутся из целевой модели. Объект только с ключом тоже считается обновлением: в PUT он должен содержать обязательные поля модели. При замене сохраняются первичный ключ, генерируемые значения и поля `readOnly`. В POST вложенные объекты с существующими ключами обновляются частично. Если запись по ключу не найдена, операция завершится ошибкой. Для создания вложенной записи без ключа нужна его автоматическая генерация.
|
|
267
|
+
|
|
268
|
+
Переданный список заменяет состав связи. PATCH сохраняет пропущенные связи, PUT очищает пропущенные связи, ключи которых доступны для записи. `[]` очищает список, `null` — одиночную связь с разрешённым `nullable`. Разрыв связи не удаляет связанную запись. Обязательные связи должны оставаться заполненными.
|
|
269
|
+
|
|
270
|
+
В одном объекте указывайте либо связь, либо её хранимый ключ: например, `genres` или `genreIds`. Для обратной связи сервер меняет целевой ключ. Если путь проходит через массив и нельзя однозначно выбрать элемент для связи, передайте массив с нужными ключами явно. Защищённые ключи изменять нельзя.
|
|
271
|
+
|
|
272
|
+
Все вложенные изменения входят в транзакцию основной записи. Ошибка проверки, отсутствующая запись или неверный выбор полей ответа отменяет всю операцию. Изменения общей записи видны всем, кто с ней связан.
|
|
273
|
+
|
|
274
|
+
GraphQL принимает в полях связей типизированные объекты. Чтобы при замене изменить только связи, используйте поля ключей, например `genreIds`. Пример:
|
|
275
|
+
|
|
276
|
+
```graphql
|
|
277
|
+
mutation {
|
|
278
|
+
movieUpdate(id: "1", data: {
|
|
279
|
+
actors: [{
|
|
280
|
+
userId: "1"
|
|
281
|
+
genres: [{ id: "2", name: "Обновлённый жанр" }, { name: "Новый жанр" }]
|
|
282
|
+
}]
|
|
283
|
+
}) {
|
|
284
|
+
actors { data { genres { data { id name } } } }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Параметры REST-запросов
|
|
290
|
+
|
|
291
|
+
REST принимает один query-параметр `scope` с JSON-массивом `[поля, аргументы?]`. Первый объект выбирает поля, второй задаёт `where`, `order` и `pager` для списка. Этот формат одинаков для корневого запроса, вложенных объектов и связей.
|
|
292
|
+
|
|
293
|
+
Пример выбора пользователей и их фильмов с отдельной сортировкой и пагинацией:
|
|
245
294
|
|
|
246
295
|
```js
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
296
|
+
const scope = [
|
|
297
|
+
{
|
|
298
|
+
id: true,
|
|
299
|
+
fullName: true,
|
|
300
|
+
movies: [
|
|
301
|
+
{ id: true, title: true },
|
|
302
|
+
{
|
|
303
|
+
order: [{ field: 'title', direction: 'ASC' }],
|
|
304
|
+
pager: { page: 1, pageSize: 5 },
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
where: { fullName: { contains: 'Мира' } },
|
|
310
|
+
order: [{ field: 'fullName', direction: 'ASC' }],
|
|
311
|
+
pager: { page: 1, pageSize: 20 },
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
const params = new URLSearchParams({ scope: JSON.stringify(scope) });
|
|
251
315
|
const response = await fetch(`/users?${params}`);
|
|
252
316
|
```
|
|
253
317
|
|
|
254
|
-
|
|
318
|
+
Обычные поля выбираются через `true`, объекты и связи — через свой массив `scope`. Если аргументы не нужны, в массиве остаётся только объект полей. `"*": true` включает собственные поля и хранимые ключи, кроме `writeOnly`; связи выбираются явно.
|
|
255
319
|
|
|
256
|
-
|
|
320
|
+
Например, собственные поля фильма, пользователи актёров и отсортированные жанры:
|
|
257
321
|
|
|
258
322
|
```json
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
"
|
|
263
|
-
|
|
323
|
+
[
|
|
324
|
+
{
|
|
325
|
+
"*": true,
|
|
326
|
+
"actors": [
|
|
327
|
+
{
|
|
328
|
+
"user": [{ "id": true, "fullName": true }],
|
|
329
|
+
"genres": [
|
|
330
|
+
{ "*": true },
|
|
331
|
+
{ "order": [{ "field": "name", "direction": "ASC" }] }
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
]
|
|
264
335
|
}
|
|
265
|
-
|
|
336
|
+
]
|
|
266
337
|
```
|
|
267
338
|
|
|
268
|
-
|
|
339
|
+
Без `scope` возвращаются собственные поля, как при `[{"*":true}]`. Пустой выбор `[{}]` возвращает объект без полей. Списки сохраняют структуру `{ data, total }`.
|
|
340
|
+
|
|
341
|
+
Аргументы доступны только у списков. В запросе отдельной записи и в ответе мутации их можно задать для вложенных списков. Параметры проверяются даже на пустых данных; ошибка в выборе ответа отменяет изменения записи. Некорректный `scope` возвращает `400`. Максимальная длина JSON — 10 000 символов, глубина выбора — 32 уровня.
|
|
342
|
+
|
|
343
|
+
OpenAPI остаётся версии 3.0.3. В этой версии нельзя задать отдельную схему для каждой позиции массива: назначение элементов описано в документации, а их порядок строго проверяет сервер.
|
|
269
344
|
|
|
270
345
|
### GraphQL
|
|
271
346
|
|
package/dist/src/constants.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export declare const DEFAULT_MAX_FILE_SIZE: number;
|
|
|
3
3
|
export declare const DEFAULT_MAX_PAGE_SIZE = 100;
|
|
4
4
|
export declare const DEFAULT_PAGE_SIZE = 10;
|
|
5
5
|
export declare const DEFAULT_PORT = 4001;
|
|
6
|
-
export declare const VERSION = "1.0.0-alpha.
|
|
6
|
+
export declare const VERSION = "1.0.0-alpha.5";
|
package/dist/src/constants.js
CHANGED
|
@@ -3,5 +3,5 @@ export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
|
3
3
|
export const DEFAULT_MAX_PAGE_SIZE = 100;
|
|
4
4
|
export const DEFAULT_PAGE_SIZE = 10;
|
|
5
5
|
export const DEFAULT_PORT = 4001;
|
|
6
|
-
export const VERSION = '1.0.0-alpha.
|
|
6
|
+
export const VERSION = '1.0.0-alpha.5';
|
|
7
7
|
//# sourceMappingURL=constants.js.map
|
package/dist/src/engine.d.ts
CHANGED
|
@@ -2,22 +2,8 @@ import type { DatabaseStore } from './database.js';
|
|
|
2
2
|
import { type Entity, type Model, type Node } from './model.js';
|
|
3
3
|
import { type Predicate } from './query/filter.js';
|
|
4
4
|
import { type ListOptions } from './query/options.js';
|
|
5
|
-
import type
|
|
6
|
-
|
|
7
|
-
export interface Ref {
|
|
8
|
-
[REF]: true;
|
|
9
|
-
entity: Entity;
|
|
10
|
-
node: Node;
|
|
11
|
-
value: JsonObject;
|
|
12
|
-
root: JsonObject;
|
|
13
|
-
bindings: Record<string, JsonObject>;
|
|
14
|
-
context: Context;
|
|
15
|
-
}
|
|
16
|
-
export interface Context {
|
|
17
|
-
data: DatabaseData;
|
|
18
|
-
model: Model;
|
|
19
|
-
indexes: Map<string, Map<string, JsonObject[]>>;
|
|
20
|
-
}
|
|
5
|
+
import { type Context, type Ref } from './records.js';
|
|
6
|
+
import type { DatabaseData } from './types.js';
|
|
21
7
|
export interface PreparedList {
|
|
22
8
|
page: number;
|
|
23
9
|
pageSize: number;
|
|
@@ -31,11 +17,6 @@ export interface Page {
|
|
|
31
17
|
data: Ref[];
|
|
32
18
|
total: number;
|
|
33
19
|
}
|
|
34
|
-
export declare const makeContext: (data: DatabaseData, model: Model) => Context;
|
|
35
|
-
export declare const rootRef: (context: Context, entity: Entity, value: JsonObject) => Ref;
|
|
36
|
-
export declare function related(ref: Ref, node: Node): Ref[];
|
|
37
|
-
export declare function resolveField(ref: Ref, node: Node): unknown;
|
|
38
|
-
export declare const isRef: (value: unknown) => value is Ref;
|
|
39
20
|
export declare class Engine {
|
|
40
21
|
readonly store: DatabaseStore;
|
|
41
22
|
model: Model;
|
|
@@ -50,9 +31,6 @@ export declare class Engine {
|
|
|
50
31
|
prepareOptions(node: Node, options?: ListOptions): PreparedList;
|
|
51
32
|
list(records: Ref[], node: Node, options?: ListOptions, prepared?: PreparedList): Page;
|
|
52
33
|
validateData(data: DatabaseData): void;
|
|
53
|
-
private defaults;
|
|
54
|
-
private preserve;
|
|
55
34
|
mutate<T = Ref>(entity: Entity, mode: 'create' | 'replace' | 'update' | 'delete', key?: unknown, body?: unknown, prepare?: (ref: Ref) => T): Promise<T>;
|
|
56
35
|
private cascade;
|
|
57
36
|
}
|
|
58
|
-
export {};
|
package/dist/src/engine.js
CHANGED
|
@@ -1,58 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from './constants.js';
|
|
3
|
-
import { createId } from './database.js';
|
|
4
2
|
import { domainError } from './errors.js';
|
|
5
|
-
import {
|
|
3
|
+
import { inferModel, isReverseRelation, pathParts, readPath, validateRecord } from './model.js';
|
|
4
|
+
import { MutationWriter } from './mutations/write.js';
|
|
6
5
|
import { compileWhere } from './query/filter.js';
|
|
7
6
|
import { badQuery, childrenOf, nodeAt } from './query/options.js';
|
|
7
|
+
import { isRef, keyOf, makeContext, related, resolveField, rootRef, sourceValues } from './records.js';
|
|
8
8
|
import { isObject } from './utils.js';
|
|
9
|
-
const REF = Symbol('record reference');
|
|
10
|
-
export const makeContext = (data, model) => ({ data, model, indexes: new Map() });
|
|
11
|
-
export const rootRef = (context, entity, value) => ({ [REF]: true, context, entity, node: entity.root, value, root: value, bindings: {} });
|
|
12
|
-
const keyOf = (value) => `${typeof value}:${String(value)}`;
|
|
13
|
-
function sourceValues(ref, node) {
|
|
14
|
-
const path = node.source;
|
|
15
|
-
const binding = Object.keys(ref.bindings)
|
|
16
|
-
.filter((prefix) => path === prefix || path.startsWith(`${prefix}.`))
|
|
17
|
-
.sort((a, b) => b.length - a.length)[0];
|
|
18
|
-
return binding ? readPath(ref.bindings[binding], path === binding ? [] : path.slice(binding.length + 1)) : readPath(ref.root, path);
|
|
19
|
-
}
|
|
20
|
-
export function related(ref, node) {
|
|
21
|
-
const entity = node.relation;
|
|
22
|
-
const indexKey = `${entity.collection}:${node.target}`;
|
|
23
|
-
let index = ref.context.indexes.get(indexKey);
|
|
24
|
-
if (!index) {
|
|
25
|
-
index = new Map();
|
|
26
|
-
for (const record of ref.context.data[entity.collection] ?? [])
|
|
27
|
-
for (const value of readPath(record, node.target)) {
|
|
28
|
-
const key = keyOf(value);
|
|
29
|
-
const bucket = index.get(key) ?? [];
|
|
30
|
-
bucket.push(record);
|
|
31
|
-
index.set(key, bucket);
|
|
32
|
-
}
|
|
33
|
-
ref.context.indexes.set(indexKey, index);
|
|
34
|
-
}
|
|
35
|
-
const found = new Set();
|
|
36
|
-
for (const value of sourceValues(ref, node))
|
|
37
|
-
for (const record of index.get(keyOf(value)) ?? [])
|
|
38
|
-
found.add(record);
|
|
39
|
-
return [...found].map((record) => rootRef(ref.context, entity, record));
|
|
40
|
-
}
|
|
41
|
-
export function resolveField(ref, node) {
|
|
42
|
-
if (node.relation) {
|
|
43
|
-
const records = related(ref, node);
|
|
44
|
-
return node.many ? records : (records[0] ?? null);
|
|
45
|
-
}
|
|
46
|
-
const key = childName(node);
|
|
47
|
-
const value = Object.hasOwn(ref.value, key) ? ref.value[key] : undefined;
|
|
48
|
-
if ((ref.context.model.explicit && node.base !== 'object') || value == null)
|
|
49
|
-
return value;
|
|
50
|
-
const wrap = (object) => ({ ...ref, node, value: object, bindings: { ...ref.bindings, [node.path]: object } });
|
|
51
|
-
if (Array.isArray(value))
|
|
52
|
-
return value.every(isObject) ? value.map(wrap) : value;
|
|
53
|
-
return isObject(value) ? wrap(value) : value;
|
|
54
|
-
}
|
|
55
|
-
export const isRef = (value) => isObject(value) && value[REF] === true;
|
|
56
9
|
function filterView(ref) {
|
|
57
10
|
const value = {};
|
|
58
11
|
for (const [name, node] of Object.entries(childrenOf(ref.node)))
|
|
@@ -169,7 +122,7 @@ export class Engine {
|
|
|
169
122
|
if (node.required && !matches.length)
|
|
170
123
|
throw domainError('INVALID_INPUT', `Required relation ${ref.entity.name}.${node.path} is empty`);
|
|
171
124
|
const values = sourceValues(ref, node);
|
|
172
|
-
if (
|
|
125
|
+
if (!isReverseRelation(ref.entity, node) && values.some((value) => !matches.some((match) => readPath(match.value, node.target).some((target) => keyOf(target) === keyOf(value)))))
|
|
173
126
|
throw domainError('INVALID_INPUT', `Dangling relation ${ref.entity.name}.${node.path}`);
|
|
174
127
|
}
|
|
175
128
|
else if (node.base === 'object') {
|
|
@@ -189,46 +142,11 @@ export class Engine {
|
|
|
189
142
|
visit(rootRef(context, entity, record));
|
|
190
143
|
}
|
|
191
144
|
}
|
|
192
|
-
defaults(node, record) {
|
|
193
|
-
for (const [key, child] of Object.entries(node.children)) {
|
|
194
|
-
if (child.relation)
|
|
195
|
-
continue;
|
|
196
|
-
if (!Object.hasOwn(record, key) && child.default !== undefined)
|
|
197
|
-
record[key] = structuredClone(child.default);
|
|
198
|
-
if (child.base === 'object' && record[key] != null) {
|
|
199
|
-
const values = child.many ? record[key] : [record[key]];
|
|
200
|
-
values.forEach((value) => {
|
|
201
|
-
this.defaults(child, value);
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
preserve(node, record, previous) {
|
|
207
|
-
for (const [key, child] of Object.entries(node.children)) {
|
|
208
|
-
if (child.relation)
|
|
209
|
-
continue;
|
|
210
|
-
if ((child.generated || child.readOnly) && previous && Object.hasOwn(previous, key))
|
|
211
|
-
record[key] = structuredClone(previous[key]);
|
|
212
|
-
else if (child.base === 'object' && !child.many) {
|
|
213
|
-
const old = previous?.[key];
|
|
214
|
-
if (isObject(record[key]))
|
|
215
|
-
this.preserve(child, record[key], isObject(old) ? old : undefined);
|
|
216
|
-
else if (record[key] === undefined && isObject(old)) {
|
|
217
|
-
const preserved = {};
|
|
218
|
-
this.preserve(child, preserved, old);
|
|
219
|
-
if (Object.keys(preserved).length)
|
|
220
|
-
record[key] = preserved;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
145
|
async mutate(entity, mode, key, body, prepare) {
|
|
226
146
|
if (mode !== 'delete') {
|
|
227
147
|
if (!isObject(body))
|
|
228
148
|
throw domainError('INVALID_INPUT', 'Request body must be an object');
|
|
229
|
-
if (this.model.explicit)
|
|
230
|
-
validateRecord(entity, body, mode);
|
|
231
|
-
else if (Object.hasOwn(body, 'id'))
|
|
149
|
+
if (!this.model.explicit && Object.hasOwn(body, 'id'))
|
|
232
150
|
throw domainError('INVALID_INPUT', 'id is generated and immutable');
|
|
233
151
|
}
|
|
234
152
|
const outcome = await this.store.update((database) => {
|
|
@@ -251,51 +169,19 @@ export class Engine {
|
|
|
251
169
|
throw domainError('CONFLICT', 'Invalid increment counter');
|
|
252
170
|
counters[name] = maximum;
|
|
253
171
|
}
|
|
254
|
-
const context = makeContext(database.data, this.model);
|
|
255
|
-
const current = mode === 'create' ? undefined : this.find(context, entity, key);
|
|
256
|
-
if (mode !== 'create' && !current)
|
|
257
|
-
throw domainError('NOT_FOUND', 'Record not found');
|
|
258
|
-
database.data[entity.collection] ??= [];
|
|
259
|
-
const collection = database.data[entity.collection];
|
|
260
172
|
if (mode === 'delete') {
|
|
173
|
+
const context = makeContext(database.data, this.model);
|
|
174
|
+
const current = this.find(context, entity, key);
|
|
175
|
+
if (!current)
|
|
176
|
+
throw domainError('NOT_FOUND', 'Record not found');
|
|
261
177
|
const snapshot = structuredClone(database.data);
|
|
262
178
|
this.cascade(context, current);
|
|
263
179
|
this.validateData(database.data);
|
|
264
180
|
return finish(snapshot, current.value);
|
|
265
181
|
}
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
this.preserve(entity.root, record, current?.value);
|
|
270
|
-
}
|
|
271
|
-
if (this.model.explicit) {
|
|
272
|
-
if (mode !== 'update')
|
|
273
|
-
this.defaults(entity.root, record);
|
|
274
|
-
if (mode === 'create')
|
|
275
|
-
for (const [name, field] of Object.entries(entity.root.children)) {
|
|
276
|
-
if (field.generated === 'uuid')
|
|
277
|
-
record[name] = randomUUID();
|
|
278
|
-
if (field.generated === 'increment') {
|
|
279
|
-
database.counters ??= {};
|
|
280
|
-
const counters = database.counters;
|
|
281
|
-
const counterKey = `${entity.collection}.${name}`;
|
|
282
|
-
const largest = counters[counterKey] ?? 0;
|
|
283
|
-
if (!Number.isSafeInteger(largest) || largest >= Number.MAX_SAFE_INTEGER)
|
|
284
|
-
throw domainError('CONFLICT', 'Increment key exhausted');
|
|
285
|
-
record[name] = largest + 1;
|
|
286
|
-
counters[counterKey] = largest + 1;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
else if (mode === 'create')
|
|
291
|
-
record.id = createId(collection);
|
|
292
|
-
const collision = collection.some((v) => v !== current?.value && String(v[entity.primary]) === String(record[entity.primary]));
|
|
293
|
-
if (collision)
|
|
294
|
-
throw domainError('CONFLICT', 'Primary key already exists');
|
|
295
|
-
if (mode === 'create')
|
|
296
|
-
collection.push(record);
|
|
297
|
-
else
|
|
298
|
-
collection[collection.indexOf(current.value)] = record;
|
|
182
|
+
const writer = new MutationWriter(database, this.model, mode === 'replace' ? 'replace' : 'update');
|
|
183
|
+
const record = writer.write(entity, mode, key, body);
|
|
184
|
+
writer.validateRelations();
|
|
299
185
|
this.validateData(database.data);
|
|
300
186
|
return finish(database.data, record);
|
|
301
187
|
});
|