@itd-api/cache 0.1.0 → 0.3.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
@@ -12,8 +12,7 @@ TTL/LRU-кэш и дедупликация одновременных запро
12
12
  npm install itd-api @itd-api/cache
13
13
  ```
14
14
 
15
- Поддерживается `itd-api >=0.5.0 <1.0.0`: cache использует namespace
16
- `RequestOptions.extensions` и стабильный `operationId` запроса.
15
+ Требуется `itd-api >=0.9.0 <1.0.0`.
17
16
 
18
17
  ## Быстрый старт
19
18
 
@@ -33,13 +32,17 @@ const first = await itd.posts.get(postId); // запрос к API
33
32
  const second = await itd.posts.get(postId); // ответ из кэша
34
33
  ```
35
34
 
35
+ Операции подключаемых модулей тоже можно кэшировать. Например, модуль Pixel Battle задаёт
36
+ `CachePolicyKind.Query` в `annotations.cache`, а пользователь добавляет полный `operationId`
37
+ в `operations`.
38
+
36
39
  Кэшируются только перечисленные операции. Query, path и body входят в ключ, поэтому
37
40
  разные страницы ленты, профили и наборы идентификаторов хранятся отдельно. Одинаковые
38
41
  запросы, запущенные одновременно, выполняют один сетевой вызов.
39
42
 
40
- Имена в `operations` — стабильные `operationId`, а не HTTP-пути. Поэтому перенос endpoint не ломает
41
- правила кэша. Низкоуровневый `itd.request()` без явного ID считается операцией `raw`: одно лишь
42
- совпадение его URL со встроенным resource не включает кэш автоматически.
43
+ Имена в `operations` — постоянные `operationId`, а не HTTP-пути. Изменение маршрута не ломает
44
+ правила кэша. Низкоуровневый `itd.request()` без явного идентификатора операции считается
45
+ операцией `raw`; совпадение URL со встроенным ресурсом не включает кэш автоматически.
43
46
 
44
47
  ## Настройки
45
48
 
@@ -100,11 +103,11 @@ cached.clear();
100
103
  `clear()` очищает всё хранилище. Оба метода защищены от гонки: запрос, начатый до очистки,
101
104
  не запишет устаревший результат после неё.
102
105
 
103
- ## Realtime
106
+ ## События
104
107
 
105
108
  ```ts
106
- const stream = itd.realtime();
107
- const detachCache = cached.attachRealtime(stream);
109
+ const stream = itd.notifications.events;
110
+ const detachCache = cached.attachNotificationEvents(stream);
108
111
 
109
112
  await stream.connect();
110
113
 
@@ -118,8 +121,12 @@ stream.disconnect();
118
121
  пользователем, событие `unreadCount` — их счётчик. Кэш других аккаунтов и остальные операции
119
122
  поток не изменяет.
120
123
 
121
- У стороннего realtime-объекта без доступных идентификатора пользователя и базового URL
122
- используется безопасный fallback: операции уведомлений инвалидируются во всех аккаунтах.
124
+ Инвалидация подключается как промежуточный обработчик. Вызывайте
125
+ `attachNotificationEvents()` до прикладных обработчиков, которые могут не вызвать `next()`:
126
+ тогда отфильтрованное для интерфейса обновление всё равно не оставит устаревший кэш.
127
+
128
+ У стороннего событийного объекта без доступных идентификатора пользователя и базового URL
129
+ операции уведомлений инвалидируются во всех аккаунтах.
123
130
 
124
131
  ## Несколько клиентов
125
132
 
@@ -151,7 +158,7 @@ clientB.use(shared);
151
158
  копиями не объединяет.
152
159
 
153
160
  `maxEntries` ограничивает весь экземпляр `shared`, а `clear()` и `invalidate()` управляют
154
- всеми его разделами. `attachRealtime()` затрагивает аккаунт создавшего поток клиента.
161
+ всеми его разделами. `attachNotificationEvents()` затрагивает аккаунт создавшего поток клиента.
155
162
  Смена сессии сохраняет общий кэш, смена пользователя автоматически выбирает другой раздел.
156
163
 
157
164
  ## Ключ
@@ -161,11 +168,11 @@ clientB.use(shared);
161
168
  - имя операции, HTTP-метод и path;
162
169
  - базовый URL и идентификатор пользователя; для `auth.sessions` также идентификатор сессии;
163
170
  - `service` или разовый `baseUrl`;
164
- - query и JSON-body;
171
+ - параметры строки запроса и JSON-тело;
165
172
  - режим `raw`, `skipAuth` и опции других плагинов, влияющие на ответ.
166
173
 
167
174
  Токен и заголовки в ключ не входят. Также не учитываются `signal`, `timeout` и настройки
168
- повторов. Несериализуемый JSON-body выполняется без кэширования.
175
+ повторов. Запрос с несериализуемым JSON-телом выполняется без кэширования.
169
176
 
170
177
  Ответ хранится как независимая копия: изменение полученного объекта не меняет следующие
171
178
  результаты.
@@ -184,7 +191,8 @@ clientB.use(shared);
184
191
  | Files | `files.get` |
185
192
  | Subscription | `subscription.status`, `subscription.methods` |
186
193
  | Verification | `verification.status` |
187
- | Platform | `platform.changelog`, `platform.announcements`, `platform.portal`, `platform.status` |
194
+ | Platform | `platform.changelog`, `platform.announcements`, `platform.portal`, `status.get` |
195
+ | Shop | `shop.products.list`, `shop.products.get`, `shop.delivery.countries`, `shop.delivery.cities`, `shop.delivery.points`, `shop.delivery.calculate` |
188
196
 
189
197
  Страницы, загружаемые итераторами, используют операцию соответствующего списочного метода:
190
198
  `posts.iterate()` — `posts.list`, `users.iterateFollowers()` — `users.followers` и так
package/dist/index.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let itd_api = require("itd-api");
2
3
  let lru_cache = require("lru-cache");
3
4
  //#region src/errors.ts
4
5
  /** Ошибка настройки или использования плагина кэша. */
@@ -210,8 +211,32 @@ const CACHE_OPERATIONS = freezeOperations([
210
211
  category: "platform"
211
212
  },
212
213
  {
213
- id: "platform.status",
214
+ id: "status.get",
214
215
  category: "platform"
216
+ },
217
+ {
218
+ id: "shop.products.list",
219
+ category: "shop"
220
+ },
221
+ {
222
+ id: "shop.products.get",
223
+ category: "shop"
224
+ },
225
+ {
226
+ id: "shop.delivery.countries",
227
+ category: "shop"
228
+ },
229
+ {
230
+ id: "shop.delivery.cities",
231
+ category: "shop"
232
+ },
233
+ {
234
+ id: "shop.delivery.points",
235
+ category: "shop"
236
+ },
237
+ {
238
+ id: "shop.delivery.calculate",
239
+ category: "shop"
215
240
  }
216
241
  ]);
217
242
  const OPERATIONS = new Map(CACHE_OPERATIONS.map((operation) => [operation.id, operation]));
@@ -224,6 +249,20 @@ function cacheOperation(operationId) {
224
249
  return OPERATIONS.get(operationId);
225
250
  }
226
251
  //#endregion
252
+ //#region src/policy.ts
253
+ /** Виды политики кэша, объявляемой в метаданных операции. */
254
+ const CachePolicyKind = Object.freeze({
255
+ Query: "query",
256
+ Mutation: "mutation"
257
+ });
258
+ /** Области изоляции данных кэша. */
259
+ const CachePolicyScope = Object.freeze({
260
+ Account: "account",
261
+ Session: "session"
262
+ });
263
+ /** Способы инвалидации кэша после мутации. */
264
+ const CacheInvalidation = Object.freeze({ All: "all" });
265
+ //#endregion
227
266
  //#region src/mutations.ts
228
267
  const POST_CONTENT = [
229
268
  "posts.list",
@@ -337,37 +376,37 @@ const CACHE_MUTATIONS = Object.freeze([
337
376
  },
338
377
  {
339
378
  operationId: "auth.signUp",
340
- invalidates: "all"
379
+ invalidates: CacheInvalidation.All
341
380
  },
342
381
  {
343
382
  operationId: "auth.signIn",
344
- invalidates: "all"
383
+ invalidates: CacheInvalidation.All
345
384
  },
346
385
  {
347
386
  operationId: "auth.verifyOtp",
348
- invalidates: "all"
387
+ invalidates: CacheInvalidation.All
349
388
  },
350
389
  {
351
390
  operationId: "auth.logout",
352
- invalidates: "all"
391
+ invalidates: CacheInvalidation.All
353
392
  },
354
393
  {
355
394
  operationId: "auth.resetPassword",
356
- invalidates: "all"
395
+ invalidates: CacheInvalidation.All
357
396
  },
358
397
  {
359
398
  operationId: "auth.changePassword",
360
- invalidates: "all"
399
+ invalidates: CacheInvalidation.All
361
400
  },
362
401
  {
363
402
  operationId: "auth.revokeSession",
364
403
  invalidates: ["auth.sessions"],
365
- scope: "account"
404
+ scope: CachePolicyScope.Account
366
405
  },
367
406
  {
368
407
  operationId: "auth.revokeOtherSessions",
369
408
  invalidates: ["auth.sessions"],
370
- scope: "account"
409
+ scope: CachePolicyScope.Account
371
410
  },
372
411
  {
373
412
  operationId: "posts.create",
@@ -492,22 +531,22 @@ const CACHE_MUTATIONS = Object.freeze([
492
531
  {
493
532
  operationId: "notifications.markRead",
494
533
  invalidates: NOTIFICATIONS,
495
- scope: "account"
534
+ scope: CachePolicyScope.Account
496
535
  },
497
536
  {
498
537
  operationId: "notifications.markReadBatch",
499
538
  invalidates: NOTIFICATIONS,
500
- scope: "account"
539
+ scope: CachePolicyScope.Account
501
540
  },
502
541
  {
503
542
  operationId: "notifications.markAllRead",
504
543
  invalidates: NOTIFICATIONS,
505
- scope: "account"
544
+ scope: CachePolicyScope.Account
506
545
  },
507
546
  {
508
547
  operationId: "notifications.updateSettings",
509
548
  invalidates: ["notifications.getSettings"],
510
- scope: "account"
549
+ scope: CachePolicyScope.Account
511
550
  },
512
551
  {
513
552
  operationId: "files.upload",
@@ -520,32 +559,32 @@ const CACHE_MUTATIONS = Object.freeze([
520
559
  {
521
560
  operationId: "verification.submit",
522
561
  invalidates: ["verification.status"],
523
- scope: "account"
562
+ scope: CachePolicyScope.Account
524
563
  },
525
564
  {
526
565
  operationId: "subscription.pay",
527
566
  invalidates: SUBSCRIPTION,
528
- scope: "account"
567
+ scope: CachePolicyScope.Account
529
568
  },
530
569
  {
531
570
  operationId: "subscription.setAutoRenewal",
532
571
  invalidates: SUBSCRIPTION,
533
- scope: "account"
572
+ scope: CachePolicyScope.Account
534
573
  },
535
574
  {
536
575
  operationId: "subscription.bindCard",
537
576
  invalidates: SUBSCRIPTION,
538
- scope: "account"
577
+ scope: CachePolicyScope.Account
539
578
  },
540
579
  {
541
580
  operationId: "subscription.setDefaultMethod",
542
581
  invalidates: SUBSCRIPTION,
543
- scope: "account"
582
+ scope: CachePolicyScope.Account
544
583
  },
545
584
  {
546
585
  operationId: "subscription.removeMethod",
547
586
  invalidates: SUBSCRIPTION,
548
- scope: "account"
587
+ scope: CachePolicyScope.Account
549
588
  },
550
589
  {
551
590
  operationId: "reports.create",
@@ -591,7 +630,7 @@ function resolveOptions(options) {
591
630
  if (!Array.isArray(options.operations) || options.operations.length === 0) throw new CacheError("cache.operations должен содержать хотя бы одну операцию");
592
631
  const operations = /* @__PURE__ */ new Set();
593
632
  for (const operation of options.operations) {
594
- if (typeof operation !== "string" || !isCacheOperationId(operation)) throw new CacheError(`Неизвестная операция кэша: ${JSON.stringify(operation)}`);
633
+ if (typeof operation !== "string" || (0, itd_api.isBuiltInOperationId)(operation) && !isCacheOperationId(operation)) throw new CacheError(`Неизвестная операция кэша: ${JSON.stringify(operation)}`);
595
634
  operations.add(operation);
596
635
  }
597
636
  const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
@@ -623,7 +662,7 @@ function cacheMode(request) {
623
662
  if (!CACHE_MODES.has(mode)) throw new CacheError(`cache должен быть '${CacheModes.Default}', '${CacheModes.Reload}' или '${CacheModes.NoStore}', получено: ${String(mode)}`);
624
663
  return mode;
625
664
  }
626
- /** Создаёт TTL/LRU-кэш разобранных ответов itd-api. */
665
+ /** Создаёт TTL/LRU-кэш нормализованных результатов itd-api. */
627
666
  function cache(options) {
628
667
  const config = resolveOptions(options);
629
668
  const values = new lru_cache.LRUCache({
@@ -649,9 +688,10 @@ function cache(options) {
649
688
  if (operations.length === 0) return;
650
689
  const selected = /* @__PURE__ */ new Set();
651
690
  for (const operation of operations) {
652
- if (!isCacheOperationId(operation)) throw new CacheError(`Неизвестная операция кэша: ${JSON.stringify(operation)}`);
653
- selected.add(operation);
654
- operationGenerations.set(operation, (operationGenerations.get(operation) ?? 0) + 1);
691
+ if (typeof operation !== "string") throw new CacheError(`Неизвестная операция кэша: ${JSON.stringify(operation)}`);
692
+ const operationId = operation;
693
+ selected.add(operationId);
694
+ operationGenerations.set(operationId, (operationGenerations.get(operationId) ?? 0) + 1);
655
695
  }
656
696
  for (const [key, entry] of values.entries()) if (selected.has(entry.operation)) values.delete(key);
657
697
  for (const [key, entry] of pending) if (selected.has(entry.operation)) pending.delete(key);
@@ -672,11 +712,24 @@ function cache(options) {
672
712
  for (const [key, entry] of pending) if (entry.accountScope === accountScope && selected.has(entry.operation)) pending.delete(key);
673
713
  };
674
714
  const applyMutation = (accountScope, mutation) => {
675
- if (mutation.invalidates === "all") clearScope(accountScope);
676
- else if (mutation.scope === "account") invalidateScope(accountScope, mutation.invalidates);
715
+ if (mutation.invalidates === CacheInvalidation.All) clearScope(accountScope);
716
+ else if (mutation.scope === CachePolicyScope.Account) invalidateScope(accountScope, mutation.invalidates);
677
717
  else invalidate(...mutation.invalidates);
678
718
  };
679
- const createTransformer = (installation, baseUrl, getAuthIdentity, getAuthScope) => {
719
+ const invalidateNotificationStream = (stream, ...operations) => {
720
+ const identity = stream.getAuthIdentity();
721
+ const streamBaseUrl = stream.baseUrl;
722
+ const legacyScope = stream.getAuthScope();
723
+ const accountScope = identity?.userId ? JSON.stringify([streamBaseUrl, identity.userId]) : legacyScope !== void 0 ? JSON.stringify([streamBaseUrl, legacyScope]) : void 0;
724
+ if (accountScope === void 0) invalidate(...operations);
725
+ else invalidateScope(accountScope, operations);
726
+ };
727
+ const notificationMiddleware = async (context, next) => {
728
+ if (context.update.type === itd_api.NotificationUpdateType.Notification) invalidateNotificationStream(context.stream, "notifications.list", "notifications.count");
729
+ else if (context.update.type === itd_api.NotificationUpdateType.UnreadCount) invalidateNotificationStream(context.stream, "notifications.count");
730
+ await next();
731
+ };
732
+ const createTransformer = (installation, baseUrl, getAuthIdentity, getAuthScope, getOperation) => {
680
733
  const fallbackAuthScope = JSON.stringify([baseUrl, `installation:${installation}`]);
681
734
  const resolveIdentity = async () => {
682
735
  const identity = await getAuthIdentity?.();
@@ -691,16 +744,24 @@ function cache(options) {
691
744
  };
692
745
  };
693
746
  return async (request, next) => {
694
- const operation = cacheOperation(request.operationId);
747
+ const policy = getOperation(request.operationId)?.annotations?.cache;
748
+ const operation = cacheOperation(request.operationId) ?? (policy?.kind === CachePolicyKind.Query ? {
749
+ id: request.operationId,
750
+ category: request.operationId.split(".", 1)[0] ?? "feature"
751
+ } : void 0);
695
752
  const method = request.method.toUpperCase();
696
- if (!(operation !== void 0 || method === "GET" || method === "HEAD")) {
697
- const mutation = cacheMutation(request.operationId);
753
+ if (!(policy !== void 0 ? policy.kind === CachePolicyKind.Query : operation !== void 0 || method === "GET" || method === "HEAD")) {
754
+ const mutation = cacheMutation(request.operationId) ?? (policy?.kind === CachePolicyKind.Mutation ? {
755
+ operationId: request.operationId,
756
+ invalidates: policy.invalidates,
757
+ ...policy.scope === void 0 ? {} : { scope: policy.scope }
758
+ } : void 0);
698
759
  const startedIdentity = mutation ? await resolveIdentity() : void 0;
699
760
  const result = await next(request);
700
761
  if (mutation && startedIdentity) {
701
762
  applyMutation(startedIdentity.accountScope, mutation);
702
763
  const currentIdentity = await resolveIdentity();
703
- if (currentIdentity.accountScope !== startedIdentity.accountScope && (mutation.invalidates === "all" || mutation.scope === "account")) applyMutation(currentIdentity.accountScope, mutation);
764
+ if (currentIdentity.accountScope !== startedIdentity.accountScope && (mutation.invalidates === CacheInvalidation.All || mutation.scope === CachePolicyScope.Account)) applyMutation(currentIdentity.accountScope, mutation);
704
765
  } else clear();
705
766
  return result;
706
767
  }
@@ -710,7 +771,7 @@ function cache(options) {
710
771
  const unscopedKey = buildCacheKey(operation.id, request);
711
772
  if (unscopedKey === void 0) return next(request);
712
773
  const identity = await resolveIdentity();
713
- const scope = operation.id === "auth.sessions" ? identity.sessionScope : identity.accountScope;
774
+ const scope = operation.id === "auth.sessions" || policy?.kind === CachePolicyKind.Query && policy.scope === CachePolicyScope.Session ? identity.sessionScope : identity.accountScope;
714
775
  const key = JSON.stringify([scope, unscopedKey]);
715
776
  if (mode === CacheModes.Reload) {
716
777
  const state = keyStates.get(key) ?? {
@@ -749,7 +810,7 @@ function cache(options) {
749
810
  const result = await next(request);
750
811
  const stored = cloneValue(result);
751
812
  const currentIdentity = await resolveIdentity();
752
- const currentScope = operation.id === "auth.sessions" ? currentIdentity.sessionScope : currentIdentity.accountScope;
813
+ const currentScope = operation.id === "auth.sessions" || policy?.kind === CachePolicyKind.Query && policy.scope === CachePolicyScope.Session ? currentIdentity.sessionScope : currentIdentity.accountScope;
753
814
  if (stored.cacheable && currentScope === scope && generation === startedGeneration && (scopeGenerations.get(identity.accountScope) ?? 0) === startedScopeGeneration && (operationGenerations.get(operation.id) ?? 0) === startedOperationGeneration && (scopeOperationGenerations.get(scopedOperationKey) ?? 0) === startedScopeOperationGeneration && keyState.generation === startedKeyGeneration) values.set(key, {
754
815
  accountScope: identity.accountScope,
755
816
  operation: operation.id,
@@ -786,34 +847,24 @@ function cache(options) {
786
847
  },
787
848
  clear,
788
849
  invalidate,
789
- attachRealtime(stream) {
790
- if (!stream || typeof stream.on !== "function") throw new CacheError("attachRealtime() принимает поток из itd.realtime()");
791
- const invalidateStream = (...operations) => {
792
- const identity = typeof stream.getAuthIdentity === "function" ? stream.getAuthIdentity() : void 0;
793
- const streamBaseUrl = typeof stream.baseUrl === "string" && stream.baseUrl.length > 0 ? stream.baseUrl : void 0;
794
- const legacyScope = typeof stream.getAuthScope === "function" ? stream.getAuthScope() : void 0;
795
- const accountScope = identity?.userId && streamBaseUrl ? JSON.stringify([streamBaseUrl, identity.userId]) : legacyScope !== void 0 && streamBaseUrl ? JSON.stringify([streamBaseUrl, legacyScope]) : void 0;
796
- if (accountScope === void 0) invalidate(...operations);
797
- else invalidateScope(accountScope, operations);
798
- };
799
- invalidateStream("notifications.list", "notifications.count");
800
- const offNotification = stream.on("notification", () => invalidateStream("notifications.list", "notifications.count"));
801
- const offUnreadCount = stream.on("unreadCount", () => invalidateStream("notifications.count"));
802
- return () => {
803
- offNotification();
804
- offUnreadCount();
805
- };
850
+ attachNotificationEvents(stream) {
851
+ if (!stream || typeof stream.use !== "function" || typeof stream.getAuthIdentity !== "function" || typeof stream.getAuthScope !== "function" || typeof stream.baseUrl !== "string") throw new CacheError("attachNotificationEvents() принимает канал itd.notifications.events");
852
+ invalidateNotificationStream(stream, "notifications.list", "notifications.count");
853
+ return stream.use(notificationMiddleware);
806
854
  },
807
855
  install({ operations, baseUrl, getAuthIdentity, getAuthScope }) {
808
856
  installationSequence += 1;
809
- operations.use(createTransformer(installationSequence, baseUrl, getAuthIdentity, getAuthScope));
857
+ operations.use(createTransformer(installationSequence, baseUrl, getAuthIdentity, getAuthScope, operations.get));
810
858
  }
811
859
  };
812
860
  }
813
861
  //#endregion
814
862
  exports.CACHE_OPERATIONS = CACHE_OPERATIONS;
815
863
  exports.CacheError = CacheError;
864
+ exports.CacheInvalidation = CacheInvalidation;
816
865
  exports.CacheModes = CacheModes;
866
+ exports.CachePolicyKind = CachePolicyKind;
867
+ exports.CachePolicyScope = CachePolicyScope;
817
868
  exports.buildCacheKey = buildCacheKey;
818
869
  exports.cache = cache;
819
870
  exports.cacheOperation = cacheOperation;