@itd-api/cache 0.0.1 → 0.0.2

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
@@ -70,9 +70,10 @@ await itd.posts.get(postId, { cache: 'no-store' });
70
70
 
71
71
  После успешной мутации плагин удаляет связанные читающие маршруты. Например, реакция на
72
72
  пост сбрасывает кэш постов и статистики, но не затрагивает профили, файлы и настройки
73
- платформы. Маршруты с общими данными инвалидируются во всех разделах экземпляра: изменение
74
- поста одним аккаунтом должно быть видно остальным. Персональные настройки, уведомления и
75
- сессии затрагивают только свой раздел.
73
+ платформы. Маршруты с общими данными инвалидируются во всех аккаунтах экземпляра: изменение
74
+ поста одним аккаунтом должно быть видно остальным. Персональные настройки и уведомления
75
+ инвалидируются у всех копий клиента с тем же пользователем. Изменение сессий сбрасывает все
76
+ варианты `auth.sessions` этого аккаунта, включая варианты других сессий.
76
77
 
77
78
  Известные запросы без зависимостей, включая telemetry и создание жалобы, кэш не меняют.
78
79
 
@@ -102,9 +103,13 @@ detachCache();
102
103
  stream.disconnect();
103
104
  ```
104
105
 
105
- Привязка сразу очищает `notifications.list` и `notifications.count`. Новое уведомление
106
- сбрасывает оба маршрута, событие `unreadCount` счётчик. Другие разделы кэша поток не
107
- изменяет.
106
+ Привязка сразу очищает `notifications.list` и `notifications.count` аккаунта, который
107
+ создал поток. Новое уведомление сбрасывает оба маршрута во всех копиях клиента с тем же
108
+ пользователем, событие `unreadCount` — их счётчик. Кэш других аккаунтов и остальные маршруты
109
+ поток не изменяет.
110
+
111
+ У стороннего realtime-объекта без доступных идентификатора пользователя и базового URL
112
+ используется безопасный fallback: маршруты уведомлений инвалидируются во всех аккаунтах.
108
113
 
109
114
  ## Несколько клиентов
110
115
 
@@ -125,21 +130,26 @@ clientB.use(shared);
125
130
  // либо accounts.use(shared)
126
131
  ```
127
132
 
128
- Каждая установка получает собственный раздел. Ответы и одновременные запросы разных
129
- клиентов не пересекаются: даже публичные профили и посты содержат персонализированные
130
- поля `isLiked`, `isFollowing` и `isReposted`. Инвалидация общей сущности применяется ко
131
- всем разделам, а персонального состояния только к клиенту, который его изменил.
133
+ Копии клиента с одинаковыми базовым URL и пользователем используют общий раздел: готовые
134
+ ответы и одновременные запросы между ними объединяются. Это безопасно для персонализированных
135
+ полей `isLiked`, `isFollowing` и `isReposted`, потому что их значения принадлежат аккаунту,
136
+ а не конкретному access token.
137
+
138
+ Разные пользователи изолированы. Только `auth.sessions` дополнительно разделяется по сессии,
139
+ поскольку ответ отмечает текущую серверную сессию. Если идентификаторов пользователя или
140
+ сессии в токене нет, плагин использует безопасный уникальный раздел установки и ничего между
141
+ копиями не объединяет.
132
142
 
133
- `maxEntries` ограничивает весь экземпляр `shared`, а `clear()`, `invalidate()` и
134
- `attachRealtime()` управляют всеми его разделами. При `setSession()`, входе или выходе
135
- раздел клиента меняется автоматически.
143
+ `maxEntries` ограничивает весь экземпляр `shared`, а `clear()` и `invalidate()` управляют
144
+ всеми его разделами. `attachRealtime()` затрагивает аккаунт создавшего поток клиента.
145
+ Смена сессии сохраняет общий кэш, смена пользователя автоматически выбирает другой раздел.
136
146
 
137
147
  ## Ключ
138
148
 
139
149
  В ключ входят:
140
150
 
141
151
  - имя маршрута, HTTP-метод и path;
142
- - непрозрачная область клиента и его текущей сессии;
152
+ - базовый URL и идентификатор пользователя; для `auth.sessions` также идентификатор сессии;
143
153
  - `service` или разовый `baseUrl`;
144
154
  - query и JSON-body;
145
155
  - режим `raw`, `skipAuth` и опции других плагинов, влияющие на ответ.
package/dist/index.cjs CHANGED
@@ -174,7 +174,7 @@ var CACHE_MUTATIONS = Object.freeze([
174
174
  method: "DELETE",
175
175
  path: /^\/api\/v1\/auth\/sessions(?:\/[^/]+)?$/,
176
176
  invalidates: ["auth.sessions"],
177
- scope: "installation"
177
+ scope: "account"
178
178
  },
179
179
  // Посты и комментарии.
180
180
  { method: "POST", path: /^\/api\/posts$/, invalidates: POST_CONTENT },
@@ -216,13 +216,13 @@ var CACHE_MUTATIONS = Object.freeze([
216
216
  method: "POST",
217
217
  path: /^\/api\/notifications\/(?:[^/]+\/read|read-batch|read-all)$/,
218
218
  invalidates: NOTIFICATIONS,
219
- scope: "installation"
219
+ scope: "account"
220
220
  },
221
221
  {
222
222
  method: "PUT",
223
223
  path: /^\/api\/notifications\/settings$/,
224
224
  invalidates: ["notifications.getSettings"],
225
- scope: "installation"
225
+ scope: "account"
226
226
  },
227
227
  // Файлы и настройки аккаунта.
228
228
  { method: "POST", path: /^\/api\/files\/upload$/, invalidates: NOTHING },
@@ -235,37 +235,37 @@ var CACHE_MUTATIONS = Object.freeze([
235
235
  method: "POST",
236
236
  path: /^\/api\/verification\/submit$/,
237
237
  invalidates: ["verification.status"],
238
- scope: "installation"
238
+ scope: "account"
239
239
  },
240
240
  {
241
241
  method: "POST",
242
242
  path: /^\/api\/v1\/subscription\/pay$/,
243
243
  invalidates: SUBSCRIPTION,
244
- scope: "installation"
244
+ scope: "account"
245
245
  },
246
246
  {
247
247
  method: "POST",
248
248
  path: /^\/api\/v1\/subscription\/auto-renewal$/,
249
249
  invalidates: SUBSCRIPTION,
250
- scope: "installation"
250
+ scope: "account"
251
251
  },
252
252
  {
253
253
  method: "POST",
254
254
  path: /^\/api\/v1\/subscription\/bind-card$/,
255
255
  invalidates: SUBSCRIPTION,
256
- scope: "installation"
256
+ scope: "account"
257
257
  },
258
258
  {
259
259
  method: "POST",
260
260
  path: /^\/api\/v1\/subscription\/methods\/[^/]+\/default$/,
261
261
  invalidates: SUBSCRIPTION,
262
- scope: "installation"
262
+ scope: "account"
263
263
  },
264
264
  {
265
265
  method: "DELETE",
266
266
  path: /^\/api\/v1\/subscription\/methods\/[^/]+$/,
267
267
  invalidates: SUBSCRIPTION,
268
- scope: "installation"
268
+ scope: "account"
269
269
  },
270
270
  // Эти запросы не меняют ни один доступный для кэширования ответ.
271
271
  { method: "POST", path: /^\/api\/reports$/, invalidates: NOTHING },
@@ -441,8 +441,16 @@ function cacheRoute(method, path) {
441
441
  }
442
442
 
443
443
  // src/plugin.ts
444
+ var CacheModes = Object.freeze({
445
+ /** Отдать свежий кэш, иначе выполнить запрос и сохранить ответ. */
446
+ Default: "default",
447
+ /** Пропустить сохранённое значение, выполнить запрос и перезаписать кэш. */
448
+ Reload: "reload",
449
+ /** Не читать и не писать кэш для этого запроса. */
450
+ NoStore: "no-store"
451
+ });
444
452
  var DEFAULT_MAX_ENTRIES = 500;
445
- var CACHE_MODES = /* @__PURE__ */ new Set(["default", "reload", "no-store"]);
453
+ var CACHE_MODES = new Set(Object.values(CacheModes));
446
454
  function assertPositive(value, name, integer = false) {
447
455
  if (!Number.isFinite(value) || value <= 0 || integer && !Number.isInteger(value)) {
448
456
  throw new CacheError(
@@ -481,10 +489,10 @@ function cloneValue(value) {
481
489
  }
482
490
  }
483
491
  function cacheMode(request) {
484
- const mode = request.cache ?? "default";
492
+ const mode = request.cache ?? CacheModes.Default;
485
493
  if (!CACHE_MODES.has(mode)) {
486
494
  throw new CacheError(
487
- `cache \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C 'default', 'reload' \u0438\u043B\u0438 'no-store', \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E: ${String(mode)}`
495
+ `cache \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C '${CacheModes.Default}', '${CacheModes.Reload}' \u0438\u043B\u0438 '${CacheModes.NoStore}', \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E: ${String(mode)}`
488
496
  );
489
497
  }
490
498
  return mode;
@@ -499,12 +507,12 @@ function cache(options) {
499
507
  });
500
508
  const pending = /* @__PURE__ */ new Map();
501
509
  const routeGenerations = /* @__PURE__ */ new Map();
502
- const installationGenerations = /* @__PURE__ */ new Map();
503
- const installationRouteGenerations = /* @__PURE__ */ new Map();
510
+ const scopeGenerations = /* @__PURE__ */ new Map();
511
+ const scopeRouteGenerations = /* @__PURE__ */ new Map();
504
512
  const keyStates = /* @__PURE__ */ new Map();
505
513
  let generation = 0;
506
514
  let installationSequence = 0;
507
- const installationRouteKey = (installation, route) => `${installation}:${route}`;
515
+ const scopeRouteKey = (accountScope, route) => JSON.stringify([accountScope, route]);
508
516
  const clear = () => {
509
517
  generation += 1;
510
518
  values.clear();
@@ -527,47 +535,48 @@ function cache(options) {
527
535
  if (selected.has(entry.route)) pending.delete(key);
528
536
  }
529
537
  };
530
- const clearInstallation = (installation) => {
531
- installationGenerations.set(installation, (installationGenerations.get(installation) ?? 0) + 1);
538
+ const clearScope = (accountScope) => {
539
+ scopeGenerations.set(accountScope, (scopeGenerations.get(accountScope) ?? 0) + 1);
532
540
  for (const [key, entry] of values.entries()) {
533
- if (entry.installation === installation) values.delete(key);
541
+ if (entry.accountScope === accountScope) values.delete(key);
534
542
  }
535
543
  for (const [key, entry] of pending) {
536
- if (entry.installation === installation) pending.delete(key);
544
+ if (entry.accountScope === accountScope) pending.delete(key);
537
545
  }
538
546
  };
539
- const invalidateInstallation = (installation, routes) => {
547
+ const invalidateScope = (accountScope, routes) => {
540
548
  if (routes.length === 0) return;
541
549
  const selected = new Set(routes);
542
550
  for (const route of selected) {
543
- const key = installationRouteKey(installation, route);
544
- installationRouteGenerations.set(key, (installationRouteGenerations.get(key) ?? 0) + 1);
551
+ const key = scopeRouteKey(accountScope, route);
552
+ scopeRouteGenerations.set(key, (scopeRouteGenerations.get(key) ?? 0) + 1);
545
553
  }
546
554
  for (const [key, entry] of values.entries()) {
547
- if (entry.installation === installation && selected.has(entry.route)) values.delete(key);
555
+ if (entry.accountScope === accountScope && selected.has(entry.route)) values.delete(key);
548
556
  }
549
557
  for (const [key, entry] of pending) {
550
- if (entry.installation === installation && selected.has(entry.route)) pending.delete(key);
558
+ if (entry.accountScope === accountScope && selected.has(entry.route)) pending.delete(key);
551
559
  }
552
560
  };
553
- const applyMutation = (installation, mutation) => {
561
+ const applyMutation = (accountScope, mutation) => {
554
562
  if (mutation.invalidates === "all") {
555
- clearInstallation(installation);
556
- } else if (mutation.scope === "installation") {
557
- invalidateInstallation(installation, mutation.invalidates);
563
+ clearScope(accountScope);
564
+ } else if (mutation.scope === "account") {
565
+ invalidateScope(accountScope, mutation.invalidates);
558
566
  } else {
559
567
  invalidate(...mutation.invalidates);
560
568
  }
561
569
  };
562
- const createTransformer = (installation, getAuthScope) => {
563
- let observedAuthScope;
564
- const resolveScope = () => {
565
- const authScope = getAuthScope ? getAuthScope() : "default";
566
- if (observedAuthScope !== void 0 && observedAuthScope !== authScope) {
567
- clearInstallation(installation);
568
- }
569
- observedAuthScope = authScope;
570
- return JSON.stringify([installation, authScope]);
570
+ const createTransformer = (installation, baseUrl, getAuthIdentity, getAuthScope) => {
571
+ const fallbackAuthScope = JSON.stringify([baseUrl, `installation:${installation}`]);
572
+ const resolveIdentity = async () => {
573
+ const identity = await getAuthIdentity?.();
574
+ const accountScope = identity?.userId ? JSON.stringify([baseUrl, identity.userId]) : getAuthScope ? JSON.stringify([baseUrl, getAuthScope()]) : fallbackAuthScope;
575
+ const sessionScope = identity?.userId && identity.sessionId ? JSON.stringify([baseUrl, identity.userId, identity.sessionId]) : JSON.stringify([
576
+ accountScope,
577
+ getAuthScope ? getAuthScope() : `installation:${installation}`
578
+ ]);
579
+ return { accountScope, sessionScope };
571
580
  };
572
581
  return async (rawRequest, next) => {
573
582
  const request = rawRequest;
@@ -575,27 +584,39 @@ function cache(options) {
575
584
  const method = request.method.toUpperCase();
576
585
  const isRead = route !== void 0 || method === "GET" || method === "HEAD";
577
586
  if (!isRead) {
578
- const result = await next(request);
579
587
  const mutation = cacheMutation(method, request.path);
580
- if (mutation) applyMutation(installation, mutation);
581
- else clear();
588
+ const startedIdentity = mutation ? await resolveIdentity() : void 0;
589
+ const result = await next(request);
590
+ if (mutation && startedIdentity) {
591
+ applyMutation(startedIdentity.accountScope, mutation);
592
+ const currentIdentity = await resolveIdentity();
593
+ if (currentIdentity.accountScope !== startedIdentity.accountScope && (mutation.invalidates === "all" || mutation.scope === "account")) {
594
+ applyMutation(currentIdentity.accountScope, mutation);
595
+ }
596
+ } else {
597
+ clear();
598
+ }
582
599
  return result;
583
600
  }
584
601
  if (!route || !config.routes.has(route.id)) return next(request);
585
602
  const mode = cacheMode(request);
586
- if (mode === "no-store") return next(request);
603
+ if (mode === CacheModes.NoStore) return next(request);
587
604
  const unscopedKey = buildCacheKey(route.id, request);
588
605
  if (unscopedKey === void 0) return next(request);
589
- const scope = resolveScope();
606
+ const identity = await resolveIdentity();
607
+ const scope = route.id === "auth.sessions" ? identity.sessionScope : identity.accountScope;
590
608
  const key = JSON.stringify([scope, unscopedKey]);
591
- if (mode === "reload") {
592
- const state = keyStates.get(key) ?? { active: 0, generation: 0 };
609
+ if (mode === CacheModes.Reload) {
610
+ const state = keyStates.get(key) ?? {
611
+ active: 0,
612
+ generation: 0
613
+ };
593
614
  state.generation += 1;
594
615
  keyStates.set(key, state);
595
616
  values.delete(key);
596
617
  pending.delete(key);
597
618
  }
598
- if (mode === "default") {
619
+ if (mode === CacheModes.Default) {
599
620
  const hit = values.get(key);
600
621
  if (hit) {
601
622
  const cloned = cloneValue(hit.value);
@@ -609,11 +630,14 @@ function cache(options) {
609
630
  }
610
631
  }
611
632
  const startedGeneration = generation;
612
- const startedInstallationGeneration = installationGenerations.get(installation) ?? 0;
633
+ const startedScopeGeneration = scopeGenerations.get(identity.accountScope) ?? 0;
613
634
  const startedRouteGeneration = routeGenerations.get(route.id) ?? 0;
614
- const scopedRouteKey = installationRouteKey(installation, route.id);
615
- const startedInstallationRouteGeneration = installationRouteGenerations.get(scopedRouteKey) ?? 0;
616
- const keyState = keyStates.get(key) ?? { active: 0, generation: 0 };
635
+ const scopedRouteKey = scopeRouteKey(identity.accountScope, route.id);
636
+ const startedScopeRouteGeneration = scopeRouteGenerations.get(scopedRouteKey) ?? 0;
637
+ const keyState = keyStates.get(key) ?? {
638
+ active: 0,
639
+ generation: 0
640
+ };
617
641
  keyState.active += 1;
618
642
  keyStates.set(key, keyState);
619
643
  const startedKeyGeneration = keyState.generation;
@@ -621,9 +645,14 @@ function cache(options) {
621
645
  try {
622
646
  const result = await next(request);
623
647
  const stored = cloneValue(result);
624
- const currentScope = resolveScope();
625
- if (stored.cacheable && currentScope === scope && generation === startedGeneration && (installationGenerations.get(installation) ?? 0) === startedInstallationGeneration && (routeGenerations.get(route.id) ?? 0) === startedRouteGeneration && (installationRouteGenerations.get(scopedRouteKey) ?? 0) === startedInstallationRouteGeneration && keyState.generation === startedKeyGeneration) {
626
- values.set(key, { installation, route: route.id, value: stored.value });
648
+ const currentIdentity = await resolveIdentity();
649
+ const currentScope = route.id === "auth.sessions" ? currentIdentity.sessionScope : currentIdentity.accountScope;
650
+ if (stored.cacheable && currentScope === scope && generation === startedGeneration && (scopeGenerations.get(identity.accountScope) ?? 0) === startedScopeGeneration && (routeGenerations.get(route.id) ?? 0) === startedRouteGeneration && (scopeRouteGenerations.get(scopedRouteKey) ?? 0) === startedScopeRouteGeneration && keyState.generation === startedKeyGeneration) {
651
+ values.set(key, {
652
+ accountScope: identity.accountScope,
653
+ route: route.id,
654
+ value: stored.value
655
+ });
627
656
  }
628
657
  return { cacheable: stored.cacheable, value: result };
629
658
  } finally {
@@ -631,8 +660,12 @@ function cache(options) {
631
660
  if (keyState.active === 0 && keyStates.get(key) === keyState) keyStates.delete(key);
632
661
  }
633
662
  })();
634
- const mayDeduplicate = mode === "default" && config.deduplicate && request.signal === void 0 && request.timeout === void 0;
635
- const entry = { installation, route: route.id, promise: load };
663
+ const mayDeduplicate = mode === CacheModes.Default && config.deduplicate && request.signal === void 0 && request.timeout === void 0;
664
+ const entry = {
665
+ accountScope: identity.accountScope,
666
+ route: route.id,
667
+ promise: load
668
+ };
636
669
  if (mayDeduplicate) pending.set(key, entry);
637
670
  try {
638
671
  const loaded = await load;
@@ -655,26 +688,38 @@ function cache(options) {
655
688
  if (!stream || typeof stream.on !== "function") {
656
689
  throw new CacheError("attachRealtime() \u043F\u0440\u0438\u043D\u0438\u043C\u0430\u0435\u0442 \u043F\u043E\u0442\u043E\u043A \u0438\u0437 itd.realtime()");
657
690
  }
658
- invalidate("notifications.list", "notifications.count");
691
+ const invalidateStream = (...routes) => {
692
+ const identity = typeof stream.getAuthIdentity === "function" ? stream.getAuthIdentity() : void 0;
693
+ const streamBaseUrl = typeof stream.baseUrl === "string" && stream.baseUrl.length > 0 ? stream.baseUrl : void 0;
694
+ const legacyScope = typeof stream.getAuthScope === "function" ? stream.getAuthScope() : void 0;
695
+ const accountScope = identity?.userId && streamBaseUrl ? JSON.stringify([streamBaseUrl, identity.userId]) : legacyScope !== void 0 && streamBaseUrl ? JSON.stringify([streamBaseUrl, legacyScope]) : void 0;
696
+ if (accountScope === void 0) invalidate(...routes);
697
+ else invalidateScope(accountScope, routes);
698
+ };
699
+ invalidateStream("notifications.list", "notifications.count");
659
700
  const offNotification = stream.on(
660
701
  "notification",
661
- () => invalidate("notifications.list", "notifications.count")
702
+ () => invalidateStream("notifications.list", "notifications.count")
703
+ );
704
+ const offUnreadCount = stream.on(
705
+ "unreadCount",
706
+ () => invalidateStream("notifications.count")
662
707
  );
663
- const offUnreadCount = stream.on("unreadCount", () => invalidate("notifications.count"));
664
708
  return () => {
665
709
  offNotification();
666
710
  offUnreadCount();
667
711
  };
668
712
  },
669
- install({ use, getAuthScope }) {
713
+ install({ use, baseUrl, getAuthIdentity, getAuthScope }) {
670
714
  installationSequence += 1;
671
- use(createTransformer(installationSequence, getAuthScope));
715
+ use(createTransformer(installationSequence, baseUrl, getAuthIdentity, getAuthScope));
672
716
  }
673
717
  };
674
718
  }
675
719
 
676
720
  exports.CACHE_ROUTES = CACHE_ROUTES;
677
721
  exports.CacheError = CacheError;
722
+ exports.CacheModes = CacheModes;
678
723
  exports.buildCacheKey = buildCacheKey;
679
724
  exports.cache = cache;
680
725
  exports.cacheRoute = cacheRoute;