@mirta/store 0.3.5 → 0.4.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/dist/index.mjs CHANGED
@@ -1,57 +1,525 @@
1
1
  import '@mirta/polyfills';
2
+ import { hasOwn, deepMerge } from '@mirta/basics/object';
2
3
 
3
- if ((process.env.NODE_ENV === 'test')) {
4
+ /**
5
+ * Имя текущего пакета в формате, используемом в npm-реестре.
6
+ *
7
+ * Используется для логирования, формирования сообщений об ошибках,
8
+ * а также в диагностических и пользовательских интерфейсах,
9
+ * чтобы явно указывать источник операций.
10
+ *
11
+ * Централизованное определение позволяет избежать жёсткой привязки
12
+ * к строковому значению в разных частях кода и упрощает поддержку
13
+ * при возможном переименовании пакета.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * console.log(`[${THIS_PACKAGE_NAME}] Запуск сборки...`);
18
+ * ```
19
+ *
20
+ * @since 0.4.0
21
+ *
22
+ **/
23
+ const THIS_PACKAGE_NAME = '@mirta/store';
24
+
25
+ /**
26
+ * Специализированный класс для обработки ошибок, связанных с работой хранилища Store.
27
+ *
28
+ * Предоставляет структурированные и типизированные ошибки с использованием кодов, что упрощает
29
+ * программную обработку исключений в инструментах, работающих с пакетами.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * throw StoreError.get('alreadyDefined', 'mystore')
34
+ * ```
35
+ * @since 0.4.0
36
+ *
37
+ **/
38
+ class StoreError extends Error {
39
+ /**
40
+ * Код ошибки для программной идентификации.
41
+ *
42
+ * Позволяет точно определить причину ошибки в обработчиках `try/catch`.
43
+ *
44
+ **/
45
+ code;
46
+ /**
47
+ * Приватный конструктор, используемый только внутри
48
+ * класса для создания экземпляров ошибки.
49
+ *
50
+ * @param message - Полное сообщение об ошибке.
51
+ * @param code - Код ошибки для идентификации.
52
+ * @param scope - Пространство имён или модуль, в котором возникла ошибка.
53
+ * По умолчанию — {@link THIS_PACKAGE_NAME}.
54
+ *
55
+ **/
56
+ constructor(message, code, scope) {
57
+ super(`[${scope ?? THIS_PACKAGE_NAME}] ${message}`);
58
+ this.name = 'StoreError';
59
+ this.code = code;
60
+ // Захватываем стек вызовов, исключая фабричный метод `get`,
61
+ // чтобы улучшить читаемость трассировки.
62
+ //
63
+ if ('captureStackTrace' in Error)
64
+ Error.captureStackTrace(this, scope
65
+ // eslint-disable-next-line @typescript-eslint/unbound-method
66
+ ? StoreError.getScoped
67
+ // eslint-disable-next-line @typescript-eslint/unbound-method
68
+ : StoreError.get);
69
+ }
70
+ /** Карта кодов ошибок с соответствующими сообщениями. */
71
+ static codeMappings = {
72
+ /**
73
+ * Ошибка, возникающая при попытке повторно определить хранилище.
74
+ *
75
+ * @param typeName - Идентификатор типа хранилища.
76
+ *
77
+ **/
78
+ alreadyDefined: (typeName) => `Store type "${typeName}" already defined`,
79
+ /**
80
+ * Ошибка, возникающая при попытке повторно определить хранилище,
81
+ * заданное другим модулем.
82
+ *
83
+ * @param typeName - Идентификатор типа хранилища.
84
+ * @param otherModule - Модуль, в котором тип уже определён.
85
+ *
86
+ **/
87
+ alreadyDefinedOutside: (typeName, otherModule) => `Store type "${typeName}" already defined in "${otherModule}"`,
88
+ /**
89
+ * Ошибка, возникающая при попытке присвоить значение неизменяемому полю.
90
+ * @param propertyName - Название свойства, которому нельзя присваивать значение.
91
+ *
92
+ */
93
+ readonlyProperty: (propertyName) => `Cannot assign to readonly property "${propertyName}"`,
94
+ /**
95
+ * Ошибка, возникающая при попытке обращения к несуществующему свойству хранилища.
96
+ * @param propertyName Имя отсутствующего свойства, вызвавшего ошибку.
97
+ *
98
+ **/
99
+ unknownProperty: (propertyName) => `Unknown property "${propertyName}"`,
100
+ };
101
+ /**
102
+ * Фабричный метод для создания экземпляра ошибки по её коду.
103
+ *
104
+ * Автоматически подставляет сообщение из `codeMappings` и формирует ошибку с заданными параметрами.
105
+ *
106
+ * @template T - Ограниченный ключами `codeMappings` тип, гарантирующий корректность кода.
107
+ * @param code - Код ошибки (например, `'alreadyDefined'`).
108
+ * @param args - Аргументы, соответствующие параметрам функции сообщения из `codeMappings`.
109
+ * @returns Новый экземпляр {@link StoreError} с шаблонным сообщением.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const error = StoreError.get('alreadyDefined')
114
+ * ```
115
+ */
116
+ static get(code, ...args) {
117
+ const messageFn = this.codeMappings[code];
118
+ const message = messageFn(...args);
119
+ return new StoreError(message, code);
120
+ }
121
+ /**
122
+ * Фабричный метод, аналогичный `get`, но с возможностью указать
123
+ * пользовательское пространство имён (scope).
124
+ *
125
+ * Полезно при использовании в других модулях, где нужно указать
126
+ * иной контекст ошибки.
127
+ *
128
+ * @template TKey - Тип кода ошибки, аналогично `get`.
129
+ *
130
+ * @param scope - Пространство имён ошибки (например, `'@mirta/store'`).
131
+ * @param code - Код ошибки.
132
+ * @param args - Аргументы для формирования сообщения.
133
+ *
134
+ * @returns Новый экземпляр {@link StoreError} с пользовательским
135
+ * префиксом и шаблонным сообщением.
136
+ *
137
+ * @example
138
+ *
139
+ * ```ts
140
+ * const error = StoreError.getScoped('@mirta/bot-remote', 'alreadyDefined')
141
+ * ```
142
+ **/
143
+ static getScoped(scope, code, ...args) {
144
+ const messageFn = this.codeMappings[code];
145
+ const message = messageFn(...args);
146
+ return new StoreError(message, code, scope);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Уникальный идентификатор текущей сессии выполнения скрипта.
152
+ *
153
+ * Сбрасывается при каждом перезапуске скрипта. Используется для отслеживания,
154
+ * был ли вызван `defineStore` с определённым именем в рамках одной сессии.
155
+ *
156
+ **/
157
+ let sessionId;
158
+ /**
159
+ * Возвращает уникальный идентификатор сессии.
160
+ *
161
+ * При первом вызове генерирует случайную строку на основе `Math.random()`.
162
+ * Во всех последующих вызовах возвращает уже сгенерированный идентификатор.
163
+ *
164
+ * Это позволяет:
165
+ * - Разрешить повторное определение хранилищ после перезапуска скрипта
166
+ * - Запретить повторное определение в рамках одной сессии
167
+ *
168
+ * @returns Уникальный строковый идентификатор сессии.
169
+ *
170
+ * @since 0.4.0
171
+ *
172
+ **/
173
+ function getSessionId() {
174
+ return sessionId ??= Math.random().toString(36).slice(2);
175
+ }
176
+
177
+ /**
178
+ * Пытается определить путь к файлу, вызвавшему `defineStore`, через анализ стека вызовов.
179
+ *
180
+ * Проходит по строкам стека, ищет вызов `defineStore`, а затем в следующей строке
181
+ * извлекает путь к файлу, откуда был совершён вызов.
182
+ *
183
+ * @returns Путь к файлу, вызвавшему `defineStore`, или `undefined`, если определить не удалось.
184
+ *
185
+ * @since 0.4.0
186
+ *
187
+ **/
188
+ function tryGetCallerPath() {
189
+ const errorStack = new Error().stack;
190
+ if (!errorStack)
191
+ return;
192
+ const stackLines = errorStack.split('\n');
193
+ let foundDefineStore = false;
194
+ for (const line of stackLines) {
195
+ // Ищем первую строку, содержащую "defineStore".
196
+ if (!foundDefineStore && line.includes('defineStore')) {
197
+ foundDefineStore = true;
198
+ continue;
199
+ }
200
+ // После нахождения defineStore берём следующую строку с вызовом.
201
+ if (foundDefineStore) {
202
+ // Извлекаем путь к файлу: ищем шаблон " /путь/к/файлу:строка"
203
+ //
204
+ const fileMatch = /\s([^\s]+?):\d+/.exec(line);
205
+ if (fileMatch)
206
+ return fileMatch[1];
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Получает реестр определений хранилищ из `module.static`.
212
+ *
213
+ * Реестр сохраняется между перезапусками скриптов, что позволяет отслеживать
214
+ * уже определённые типы хранилищ.
215
+ *
216
+ * @returns Реестр определений типа {@link DefinitionsRegistry}.
217
+ *
218
+ * @since 0.4.0
219
+ *
220
+ **/
221
+ function getDefinitionsRegistry() {
222
+ return (module.static.definitions ??= {});
223
+ }
224
+ /**
225
+ * Обеспечивает уникальность определения типа хранилища.
226
+ *
227
+ * Проверяет, что:
228
+ * 1. Тип хранилища не определён в другом модуле.
229
+ * 2. В текущей сессии и файле для этого типа ещё не вызывался `defineStore`.
230
+ *
231
+ * Если проверки не проходят — выбрасывается соответствующая ошибка.
232
+ *
233
+ * @param typeId - Уникальный идентификатор типа хранилища.
234
+ * @throws {StoreError} Тип уже определён в другом модуле (`alreadyDefinedOutside`).
235
+ * @throws {StoreError} Тип уже определён в текущей сессии (`alreadyDefined`).
236
+ *
237
+ * @since 0.4.0
238
+ *
239
+ **/
240
+ function enforceDefinitionIsUnique(typeId) {
241
+ const currentCallerPath = tryGetCallerPath();
242
+ if (!currentCallerPath)
243
+ return;
244
+ const registry = getDefinitionsRegistry();
245
+ // Если указанный тип хранилища ещё не зарегистрирован,
246
+ // вносим его в реестр.
247
+ //
248
+ if (!(typeId in registry))
249
+ registry[typeId] = {
250
+ callerPath: currentCallerPath,
251
+ sessions: {},
252
+ };
253
+ const entry = registry[typeId];
254
+ // Проверка 1: тип не определён в другом модуле.
255
+ if (currentCallerPath !== entry.callerPath)
256
+ throw StoreError.get('alreadyDefinedOutside', typeId, entry.callerPath);
257
+ const sessionId = getSessionId();
258
+ // Проверка 2: тип не зарегистрирован в текущей сессии.
259
+ if (entry.sessions[__filename] === sessionId)
260
+ throw StoreError.get('alreadyDefined', typeId);
261
+ // Регистрируем вызов в текущей сессии.
262
+ entry.sessions[__filename] = sessionId;
263
+ }
264
+ /**
265
+ * Сбрасывает внутреннее состояние реестра определений.
266
+ *
267
+ * Используется исключительно в тестах для обеспечения изоляции.
268
+ *
269
+ * @internal
270
+ *
271
+ * @since 0.4.0
272
+ *
273
+ **/
274
+ function __resetInternalState$1() {
275
+ if (!(process.env.NODE_ENV === 'test'))
276
+ return;
4
277
  module.static = {
5
- state: {},
278
+ definitions: {},
6
279
  };
7
280
  }
8
- const stores = {};
9
- const staticState = (module.static.state ??= {});
281
+ // Инициализация внутреннего состояния при запуске
282
+ // в режиме тестирования.
283
+ //
284
+ if ((process.env.NODE_ENV === 'test'))
285
+ __resetInternalState$1();
286
+
10
287
  const { assign } = Object;
11
- function createStore(id, options) {
12
- const { state } = options;
13
- const initialState = staticState[id];
14
- const localState = (initialState ?? (staticState[id] = state ? state() : {}));
15
- function $patch(payload) {
16
- if (typeof payload === 'function') {
17
- payload(staticState[id]);
288
+ /**
289
+ * Кэш созданных экземпляров хранилищ.
290
+ *
291
+ * Обеспечивает повторное использование одного и того же экземпляра хранилища
292
+ * при многократных вызовах `useStore()`. Ключ — идентификатор хранилища (`storeId`),
293
+ * значение сам экземпляр хранилища.
294
+ *
295
+ * @internal
296
+ *
297
+ **/
298
+ let stores = {};
299
+ // Первичная инициализация для тестов.
300
+ if ((process.env.NODE_ENV === 'test'))
301
+ __resetInternalState();
302
+ /**
303
+ * Получает или создаёт статическое состояние хранилища в `module.static`.
304
+ *
305
+ * Обеспечивает сохранение состояния между перезапусками скриптов
306
+ * и межскриптовое взаимодействие через общий контекст `module.static`.
307
+ *
308
+ * @param storeId - Уникальный идентификатор хранилища.
309
+ * @param createState - Функция, возвращающая начальное состояние хранилища.
310
+ * @returns Актуальное состояние хранилища — либо существующее, либо новое.
311
+ *
312
+ * @since 0.4.0
313
+ *
314
+ * @internal
315
+ *
316
+ **/
317
+ function getStaticState(storeId, createState) {
318
+ const states = (module.static.states ??= {});
319
+ return states[storeId] ??= createState();
320
+ }
321
+ /**
322
+ * Создаёт экземпляр хранилища с заданными параметрами.
323
+ *
324
+ * Формирует реактивный объект на основе `Proxy`, объединяющий состояние, геттеры и действия.
325
+ * Обеспечивает корректную работу `this` внутри `actions` и геттеров.
326
+ *
327
+ * @param storeId - Уникальный идентификатор экземпляра хранилища.
328
+ * @param options - Параметры хранилища: `state`, `getters`, `actions`.
329
+ * @returns Прокси-объект хранилища с полной функциональностью.
330
+ *
331
+ * @template TState - Тип дерева состояния.
332
+ * @template TGetters - Тип дерева геттеров.
333
+ * @template TActions - Тип дерева действий.
334
+ *
335
+ * @internal
336
+ *
337
+ **/
338
+ function createStore(storeId, options) {
339
+ // Функция для создания начального состояния
340
+ const createState = () => options.state ? options.state() : {};
341
+ // 1. Получение или создание общего состояния в module.static
342
+ const staticState = getStaticState(storeId, createState);
343
+ // 2. Инициализация геттеров как функций, возвращающих вычисленное значение
344
+ const getters = {};
345
+ if (options.getters) {
346
+ for (const key in options.getters) {
347
+ if (!hasOwn(options.getters, key))
348
+ continue;
349
+ const getter = options.getters[key];
350
+ // Поддержка геттеров вида (state) => value и () => value
351
+ getters[key] = typeof getter === 'function' && getter.length > 0
352
+ ? () => getter.call(null, staticState)
353
+ : () => getter.call(proxy);
354
+ }
355
+ }
356
+ // Метод для частичного обновления состояния
357
+ const $patch = (mutator) => {
358
+ if (typeof mutator === 'function') {
359
+ mutator(staticState);
18
360
  }
19
361
  else {
20
- assign(staticState[id], payload);
362
+ const merged = deepMerge(staticState, mutator);
363
+ for (const key in merged)
364
+ staticState[key] = merged[key];
21
365
  }
22
- assign(store, staticState[id]);
23
- }
24
- const $reset = function $reset() {
25
- const { state } = options;
26
- const newState = state ? state() : {};
27
- this.$patch((state) => {
28
- assign(state, newState);
29
- });
30
366
  };
31
- const partialStore = {
32
- $id: id,
367
+ // Метод для сброса состояния до начального
368
+ const $reset = () => {
369
+ const newState = createState();
370
+ $patch(state => assign(state, newState));
371
+ };
372
+ // Создание контекста `this` для действий
373
+ // Обеспечивает доступ к $state, $patch, геттерам и текущему состоянию
374
+ const actionThis = new Proxy({
375
+ $state: staticState,
376
+ $id: storeId,
33
377
  $patch,
34
378
  $reset,
379
+ ...getters,
380
+ }, {
381
+ get(target, key) {
382
+ // Состояние
383
+ if (key in staticState)
384
+ return staticState[key];
385
+ // Геттеры
386
+ if (key in getters)
387
+ return getters[key]();
388
+ if (key in actions)
389
+ return actions[key];
390
+ // Служебные свойства
391
+ if (key in target)
392
+ return target[key];
393
+ throw StoreError.get('unknownProperty', key);
394
+ },
395
+ set(target, key, value) {
396
+ if (key in target)
397
+ throw StoreError.get('readonlyProperty', key);
398
+ if (!(key in staticState))
399
+ throw StoreError.get('unknownProperty', key);
400
+ staticState[key] = value;
401
+ return true;
402
+ },
403
+ });
404
+ // Привязка действий к контексту `actionThis`
405
+ const actions = {};
406
+ if (options.actions) {
407
+ for (const key in options.actions) {
408
+ if (!hasOwn(options.actions, key))
409
+ continue;
410
+ actions[key] = options.actions[key].bind(actionThis);
411
+ }
412
+ }
413
+ // Реестр служебных свойств и методов хранилища
414
+ const internals = {
415
+ $state: () => staticState,
416
+ $id: () => storeId,
417
+ $patch: () => $patch,
418
+ $reset: () => $reset,
35
419
  };
36
- const store = assign(localState, partialStore
37
- // {
38
- // get $state() {
39
- // return staticState[id] as TState
40
- // },
41
- // set $state(state) {
42
- // $patch($state => assign($state, state))
43
- // }
44
- // }
45
- );
46
- return store;
47
- }
48
- function defineStore(id, options) {
49
- function useStore() {
50
- const store = (id in stores ? stores[id] : stores[id] = createStore(id, options));
51
- return store;
420
+ // Финальный прокси-объект хранилища
421
+ const proxy = new Proxy(staticState, {
422
+ get(target, key) {
423
+ // Состояние
424
+ if (key in target)
425
+ return target[key];
426
+ // Геттеры
427
+ if (key in getters)
428
+ return getters[key]();
429
+ // Действия
430
+ if (key in actions)
431
+ return actions[key];
432
+ // Служебные свойства
433
+ if (key in internals)
434
+ return internals[key]();
435
+ throw StoreError.get('unknownProperty', key);
436
+ },
437
+ set(target, key, value) {
438
+ if (key in internals || key in getters || key in actions)
439
+ throw StoreError.get('readonlyProperty', key);
440
+ if (!(key in target))
441
+ throw StoreError.get('unknownProperty', key);
442
+ target[key] = value;
443
+ return true;
444
+ },
445
+ has(target, key) {
446
+ return (key in internals
447
+ || key in getters
448
+ || key in actions
449
+ || key in target);
450
+ },
451
+ });
452
+ return proxy;
453
+ }
454
+ /**
455
+ * Определяет новое хранилище с заданным именем и параметрами.
456
+ *
457
+ * Является основной точкой входа для создания хранилищ.
458
+ * Возвращает функцию `useStore()`, используемую для получения экземпляра хранилища.
459
+ *
460
+ * @param type - Уникальный тип хранилища.
461
+ * @param options - Конфигурация хранилища: `state`, `getters`, `actions`.
462
+ * @returns Функция `useStore`, создающая или возвращающая экземпляр хранилища.
463
+ *
464
+ * @template TState - Тип состояния хранилища.
465
+ * @template TGetters - Тип вычисляемых свойств.
466
+ * @template TActions - Тип методов мутации состояния.
467
+ *
468
+ * @throws {StoreError} Если хранилище с таким именем уже определено.
469
+ *
470
+ * @example
471
+ * ```ts
472
+ * const useCounter = defineStore('counter', {
473
+ * state: () => ({ count: 0 }),
474
+ * getters: { double: (state) => state.count * 2 },
475
+ * actions: { increment() { this.count++ } }
476
+ * })
477
+ *
478
+ * const store = useCounter()
479
+ * ```
480
+ * @since 0.0.2
481
+ *
482
+ **/
483
+ function defineStore(typeId, options) {
484
+ enforceDefinitionIsUnique(typeId);
485
+ /**
486
+ * Возвращает экземпляр хранилища.
487
+ *
488
+ * При первом вызове создаёт хранилище, при последующих — возвращает из кэша.
489
+ * Поддерживает создание именованных экземпляров через параметр `scope`.
490
+ *
491
+ * @param scope - Опциональный ключ для создания именованного экземпляра.
492
+ * Если указан, создаётся хранилище с идентификатором `typeId/scope`.
493
+ * Если не указан — возвращается общий экземпляр хранилища `typeId`.
494
+ *
495
+ * @returns Экземпляр хранилища требуемого типа.
496
+ *
497
+ * @since 0.0.2
498
+ *
499
+ **/
500
+ function useStore(scope) {
501
+ const storeId = scope ? `${typeId}/${scope}` : typeId;
502
+ return (stores[storeId] ??= createStore(storeId, options));
52
503
  }
53
- useStore.$id = id;
504
+ useStore.$typeId = typeId;
54
505
  return useStore;
55
506
  }
507
+ /**
508
+ * Сбрасывает внутреннее состояние (для тестов).
509
+ *
510
+ * @since 0.4.0
511
+ *
512
+ * @internal
513
+ *
514
+ **/
515
+ function __resetInternalState() {
516
+ if (!(process.env.NODE_ENV === 'test'))
517
+ return;
518
+ module.static = {
519
+ states: {},
520
+ };
521
+ stores = {};
522
+ __resetInternalState$1();
523
+ }
56
524
 
57
- export { defineStore };
525
+ export { StoreError, defineStore };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mirta/store",
3
3
  "description": "Type-safe store for wb-rules",
4
- "version": "0.3.5",
4
+ "version": "0.4.0",
5
5
  "license": "Unlicense",
6
6
  "keywords": [
7
7
  "mirta",
@@ -14,6 +14,7 @@
14
14
  "LICENSE",
15
15
  "README.md"
16
16
  ],
17
+ "types": "./dist/index.d.mts",
17
18
  "exports": {
18
19
  ".": {
19
20
  "import": {
@@ -22,6 +23,10 @@
22
23
  }
23
24
  }
24
25
  },
26
+ "imports": {
27
+ "#src": "./src/index.js",
28
+ "#src/*": "./src/*.js"
29
+ },
25
30
  "homepage": "https://github.com/wb-mirta/core/tree/latest/packages/mirta-store#readme",
26
31
  "repository": {
27
32
  "type": "git",
@@ -36,10 +41,14 @@
36
41
  "url": "https://boosty.to/wihome/donate"
37
42
  },
38
43
  "dependencies": {
39
- "@mirta/polyfills": "0.3.5"
44
+ "@mirta/polyfills": "0.4.0",
45
+ "@mirta/basics": "0.4.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@mirta/basics": "0.4.0",
49
+ "@mirta/polyfills": "0.4.0"
40
50
  },
41
51
  "scripts": {
42
- "clean": "rimraf dist",
43
- "build:mono": "pnpm clean && rollup -c node:@mirta/rollup/config --config-package"
52
+ "build:mono": "rollup -c node:@mirta/rollup/config-package"
44
53
  }
45
54
  }