@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 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.3 is a prerelease.** When upgrading from 0.x, update your model schema and query parameters using the examples below.
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.3`.
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=*`. Fields with mixed value types can be read, but using them in `where`, `order` or `nested` requires an explicit schema.
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
- Query parameters `where`, `order`, `pager`, `nested` contain JSON. `scope` is a selection string. Example shown before URL encoding:
238
+ ### Nested writes
239
239
 
240
- ```text
241
- GET /users?where={"fullName":{"contains":"Мира"}}&order=[{"field":"fullName","direction":"ASC"}]&pager={"page":1,"pageSize":20}
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
- Construct encoded URLs with `URLSearchParams`:
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 params = new URLSearchParams({
248
- scope: 'id,fullName,movies(id,title)',
249
- nested: JSON.stringify({ movies: { order: [{ field: 'title', direction: 'ASC' }], pager: { page: 1, pageSize: 5 } } }),
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
- `scope=*,actors(user(id,fullName),genres(*))` selects own fields and the specified relations. `*` includes the current object's own fields and stored keys, except `writeOnly` fields. Relations are listed explicitly. The default selection is own fields. Lists retain the `{ data, total }` response structure.
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
- `nested` maps full response paths to list options:
320
+ For example, select a movie's own fields, its actors' users and sorted genres:
257
321
 
258
322
  ```json
259
- {
260
- "actors": { "pager": { "pageSize": 5 } },
261
- "actors.genres": {
262
- "where": { "id": { "in": ["2", "3"] } },
263
- "order": [{ "field": "name", "direction": "ASC" }]
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
- A path in `nested` must be selected by `scope` and point to an object list. Single-record routes and mutations accept `scope` and `nested`; root `where`, `order` and `pager` apply to collection GET. Invalid names and unsafe paths return `400`.
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.3 — предварительная версия.** При переходе с 0.x обновите схему моделей и параметры запросов по примерам ниже.
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.3`.
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=*`. Поля с разными типами значений можно читать, но для их использования в `where`, `order` или `nested` нужна явная схема.
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
- Параметры `where`, `order`, `pager`, `nested` содержат JSON; `scope` — строку выбора. Пример до URL-кодирования:
238
+ ### Вложенная запись
239
239
 
240
- ```text
241
- GET /users?where={"fullName":{"contains":"Мира"}}&order=[{"field":"fullName","direction":"ASC"}]&pager={"page":1,"pageSize":20}
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
- Кодирование через `URLSearchParams`:
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 params = new URLSearchParams({
248
- scope: 'id,fullName,movies(id,title)',
249
- nested: JSON.stringify({ movies: { order: [{ field: 'title', direction: 'ASC' }], pager: { page: 1, pageSize: 5 } } }),
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
- `scope=*,actors(user(id,fullName),genres(*))` выбирает собственные поля и указанные связи. `*` включает собственные поля текущего объекта и хранимые ключи, кроме полей `writeOnly`. Связи перечисляются явно. По умолчанию выбираются собственные поля. Списки сохраняют структуру ответа `{ data, total }`.
318
+ Обычные поля выбираются через `true`, объекты и связи через свой массив `scope`. Если аргументы не нужны, в массиве остаётся только объект полей. `"*": true` включает собственные поля и хранимые ключи, кроме `writeOnly`; связи выбираются явно.
255
319
 
256
- `nested` сопоставляет полные пути ответа и настройки списков:
320
+ Например, собственные поля фильма, пользователи актёров и отсортированные жанры:
257
321
 
258
322
  ```json
259
- {
260
- "actors": { "pager": { "pageSize": 5 } },
261
- "actors.genres": {
262
- "where": { "id": { "in": ["2", "3"] } },
263
- "order": [{ "field": "name", "direction": "ASC" }]
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
- Путь в `nested` должен быть выбран через `scope` и вести к списку объектов. Маршруты отдельных записей и мутации принимают `scope` и `nested`; корневые `where`, `order` и `pager` применяются к GET коллекции. Неизвестные имена и небезопасные пути возвращают `400`.
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
 
@@ -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.3";
6
+ export declare const VERSION = "1.0.0-alpha.5";
@@ -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.3';
6
+ export const VERSION = '1.0.0-alpha.5';
7
7
  //# sourceMappingURL=constants.js.map
@@ -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 { DatabaseData, JsonObject } from './types.js';
6
- declare const REF: unique symbol;
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 {};
@@ -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 { childName, inferModel, pathParts, readPath, validateRecord } from './model.js';
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 (node.source !== ref.entity.primary && values.some((value) => !matches.some((match) => readPath(match.value, node.target).some((target) => keyOf(target) === keyOf(value)))))
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 record = (mode === 'update' ? { ...current?.value, ...structuredClone(body) } : structuredClone(body));
267
- if (mode !== 'create') {
268
- record[entity.primary] = current.value[entity.primary];
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
  });