birdclaw 0.8.2 → 0.8.3

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -16,13 +16,9 @@ import {
16
16
  useState,
17
17
  } from "react";
18
18
  import "mapbox-gl/dist/mapbox-gl.css";
19
- import type * as GeoJSON from "geojson";
20
- import Supercluster from "supercluster";
21
19
  import type { MapRef } from "react-map-gl/mapbox";
22
- import { useSelectedAccountId } from "#/components/account-selection";
23
- import { fetchQueryEnvelope } from "#/lib/api-client";
24
- import type { NetworkMapKind, NetworkMapResponse } from "#/lib/network-map";
25
- import type { QueryEnvelope } from "#/lib/types";
20
+ import { useNetworkMapController } from "#/components/network-map-controller";
21
+ import type { NetworkMapResponse } from "#/lib/network-map";
26
22
  import {
27
23
  cx,
28
24
  errorCopyClass,
@@ -41,104 +37,32 @@ import {
41
37
  statusCopyClass,
42
38
  } from "#/lib/ui";
43
39
 
40
+ import {
41
+ CLUSTER_LEAF_SAMPLE_SIZE,
42
+ WORLD_VIEWPORT,
43
+ type ClusterAggregateProperties,
44
+ type ClusterResult,
45
+ type MapFeature,
46
+ type MapViewport,
47
+ type ReactMapboxModule,
48
+ type SelectedOverlay,
49
+ avatarInitial,
50
+ avatarPath,
51
+ buildClusterIndex,
52
+ clusterGradient,
53
+ compareClusterFeatures,
54
+ formatNumber,
55
+ formatRelationship,
56
+ getClusterDisplayAnchor,
57
+ isCluster,
58
+ readViewport,
59
+ relationshipColor,
60
+ } from "#/components/network-map-model";
61
+
44
62
  export const Route = createFileRoute("/network-map")({
45
63
  component: NetworkMapRoute,
46
64
  });
47
65
 
48
- type ReactMapboxModule = typeof import("react-map-gl/mapbox");
49
- type MapFeature = NetworkMapResponse["features"][number];
50
- type MapBounds = [number, number, number, number];
51
-
52
- interface ClusterPointProperties {
53
- featureIndex: number;
54
- handle: string;
55
- name: string;
56
- avatarUrl: string | null;
57
- relationship: MapFeature["properties"]["relationship"];
58
- followersCount: number;
59
- }
60
-
61
- interface ClusterAggregateProperties {
62
- followers: number;
63
- following: number;
64
- mutual: number;
65
- }
66
-
67
- interface ClusterFeatureProperties extends ClusterAggregateProperties {
68
- cluster: true;
69
- cluster_id: number;
70
- point_count: number;
71
- point_count_abbreviated: string | number;
72
- }
73
-
74
- type ClusterPointFeature = GeoJSON.Feature<
75
- GeoJSON.Point,
76
- ClusterPointProperties
77
- >;
78
- type ClusterFeature = GeoJSON.Feature<GeoJSON.Point, ClusterFeatureProperties>;
79
- type ClusterResult = ClusterPointFeature | ClusterFeature;
80
- type MapViewport = { bounds: MapBounds; zoom: number };
81
- type MapTarget = {
82
- getBounds: () => {
83
- getWest: () => number;
84
- getSouth: () => number;
85
- getEast: () => number;
86
- getNorth: () => number;
87
- };
88
- getZoom: () => number;
89
- };
90
-
91
- type SelectedOverlay =
92
- | { kind: "profile"; feature: MapFeature }
93
- | {
94
- kind: "cluster";
95
- coordinates: [number, number];
96
- count: number;
97
- features: MapFeature[];
98
- stats: ClusterAggregateProperties;
99
- };
100
-
101
- const CLUSTER_LEAF_SAMPLE_SIZE = 48;
102
-
103
- const MAP_TYPES: Array<{ value: NetworkMapKind; label: string }> = [
104
- { value: "all", label: "All" },
105
- { value: "followers", label: "Followers" },
106
- { value: "following", label: "Following" },
107
- { value: "mutual", label: "Mutual" },
108
- ];
109
-
110
- const WORLD_BOUNDS: MapBounds = [-180, -85, 180, 85];
111
- const WORLD_VIEWPORT: MapViewport = { bounds: WORLD_BOUNDS, zoom: 1.15 };
112
-
113
- async function fetchMap(
114
- type: NetworkMapKind,
115
- refresh: boolean,
116
- accountId?: string,
117
- signal?: AbortSignal,
118
- ) {
119
- const url = new URL("/api/network-map", window.location.origin);
120
- url.searchParams.set("type", type);
121
- url.searchParams.set("limit", "50000");
122
- url.searchParams.set("geocodeLimit", refresh ? "80" : "12");
123
- if (accountId) url.searchParams.set("account", accountId);
124
- if (refresh) url.searchParams.set("refresh", "true");
125
- const response = await fetch(url, { signal });
126
- if (!response.ok) {
127
- throw new Error(`Map request failed (${String(response.status)})`);
128
- }
129
- return (await response.json()) as NetworkMapResponse;
130
- }
131
-
132
- function formatNumber(value: number) {
133
- return new Intl.NumberFormat().format(value);
134
- }
135
-
136
- function formatRelationship(value: MapFeature["properties"]["relationship"]) {
137
- if (value === "mutual") return "mutual";
138
- if (value === "following") return "following";
139
- return "follower";
140
- }
141
-
142
66
  function StatTile({
143
67
  icon: Icon,
144
68
  label,
@@ -184,29 +108,6 @@ function useMapboxModule() {
184
108
  return { module, error };
185
109
  }
186
110
 
187
- function relationshipColor(
188
- relationship: MapFeature["properties"]["relationship"],
189
- ) {
190
- if (relationship === "mutual") return "#22c55e";
191
- if (relationship === "following") return "#f59e0b";
192
- return "#1d9bf0";
193
- }
194
-
195
- function avatarInitial(feature: MapFeature) {
196
- return (feature.properties.name || feature.properties.handle || "?")
197
- .slice(0, 1)
198
- .toUpperCase();
199
- }
200
-
201
- function avatarPath(feature: MapFeature) {
202
- if (!feature.properties.avatarUrl) return null;
203
- const query = new URLSearchParams({
204
- profileId: feature.properties.profileId,
205
- v: feature.properties.avatarUrl,
206
- });
207
- return `/api/avatar?${query.toString()}`;
208
- }
209
-
210
111
  function Avatar({
211
112
  feature,
212
113
  size = 36,
@@ -244,136 +145,6 @@ function Avatar({
244
145
  );
245
146
  }
246
147
 
247
- function clusterGradient(stats: ClusterAggregateProperties) {
248
- const total = Math.max(1, stats.followers + stats.following + stats.mutual);
249
- const mutual = (stats.mutual / total) * 100;
250
- const following = mutual + (stats.following / total) * 100;
251
- return `conic-gradient(#22c55e 0 ${mutual}%, #f59e0b ${mutual}% ${following}%, #1d9bf0 ${following}% 100%)`;
252
- }
253
-
254
- function buildClusterIndex(features: MapFeature[]) {
255
- const points: ClusterPointFeature[] = features.map(
256
- (feature, featureIndex) => ({
257
- type: "Feature",
258
- geometry: feature.geometry,
259
- properties: {
260
- featureIndex,
261
- handle: feature.properties.handle,
262
- name: feature.properties.name,
263
- avatarUrl: feature.properties.avatarUrl,
264
- relationship: feature.properties.relationship,
265
- followersCount: feature.properties.followersCount,
266
- },
267
- }),
268
- );
269
- return new Supercluster<ClusterPointProperties, ClusterAggregateProperties>({
270
- maxZoom: 18,
271
- minPoints: 2,
272
- radius: 64,
273
- map: (props) => ({
274
- followers: props.relationship === "followers" ? 1 : 0,
275
- following: props.relationship === "following" ? 1 : 0,
276
- mutual: props.relationship === "mutual" ? 1 : 0,
277
- }),
278
- reduce: (accumulated, props) => {
279
- accumulated.followers += props.followers;
280
- accumulated.following += props.following;
281
- accumulated.mutual += props.mutual;
282
- },
283
- }).load(points);
284
- }
285
-
286
- function isCluster(item: ClusterResult): item is ClusterFeature {
287
- return "cluster" in item.properties && item.properties.cluster === true;
288
- }
289
-
290
- function compareClusterFeatures(a: MapFeature, b: MapFeature) {
291
- return (
292
- b.properties.followersCount - a.properties.followersCount ||
293
- a.properties.handle.localeCompare(b.properties.handle)
294
- );
295
- }
296
-
297
- function getClusterDisplayAnchor(
298
- features: MapFeature[],
299
- fallback: [number, number],
300
- ): [number, number] {
301
- const buckets = new Map<
302
- string,
303
- { coordinates: [number, number]; count: number; followers: number }
304
- >();
305
- for (const feature of features) {
306
- const [lng, lat] = feature.geometry.coordinates;
307
- const key = `${lng.toFixed(4)},${lat.toFixed(4)}`;
308
- const existing = buckets.get(key);
309
- if (existing) {
310
- existing.count += 1;
311
- existing.followers += feature.properties.followersCount;
312
- } else {
313
- buckets.set(key, {
314
- coordinates: [lng, lat],
315
- count: 1,
316
- followers: feature.properties.followersCount,
317
- });
318
- }
319
- }
320
- const best = [...buckets.values()].sort(
321
- (a, b) => b.count - a.count || b.followers - a.followers,
322
- )[0];
323
- return best?.coordinates ?? fallback;
324
- }
325
-
326
- function readViewport(target: unknown): MapViewport | null {
327
- if (!target || typeof target !== "object") return null;
328
- const map = target as Partial<MapTarget>;
329
- if (
330
- typeof map.getBounds !== "function" ||
331
- typeof map.getZoom !== "function"
332
- ) {
333
- return null;
334
- }
335
- const bounds = map.getBounds();
336
- return {
337
- bounds: [
338
- bounds.getWest(),
339
- bounds.getSouth(),
340
- bounds.getEast(),
341
- bounds.getNorth(),
342
- ],
343
- zoom: map.getZoom(),
344
- };
345
- }
346
-
347
- function boundsContainFeature(bounds: MapBounds, feature: MapFeature) {
348
- const [west, south, east, north] = bounds;
349
- const [lng, lat] = feature.geometry.coordinates;
350
- const normalizedWest = ((west + 540) % 360) - 180;
351
- const normalizedEast = ((east + 540) % 360) - 180;
352
- const inLatitude = lat >= Math.max(-85, south) && lat <= Math.min(85, north);
353
- const inLongitude =
354
- east - west >= 360
355
- ? true
356
- : normalizedWest <= normalizedEast
357
- ? lng >= normalizedWest && lng <= normalizedEast
358
- : lng >= normalizedWest || lng <= normalizedEast;
359
- return inLatitude && inLongitude;
360
- }
361
-
362
- function featureMatchesSearch(feature: MapFeature, search: string) {
363
- const needle = search.trim().toLowerCase();
364
- if (!needle) return true;
365
- return [
366
- feature.properties.name,
367
- feature.properties.handle,
368
- feature.properties.location,
369
- feature.properties.resolvedLocation ?? "",
370
- formatRelationship(feature.properties.relationship),
371
- ]
372
- .join(" ")
373
- .toLowerCase()
374
- .includes(needle);
375
- }
376
-
377
148
  function ProfileMarker({ feature }: { feature: MapFeature }) {
378
149
  const color = relationshipColor(feature.properties.relationship);
379
150
  return (
@@ -847,95 +618,21 @@ function VisibleProfilesPanel({
847
618
  }
848
619
 
849
620
  function NetworkMapRoute() {
850
- const [type, setType] = useState<NetworkMapKind>("all");
851
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
852
- const [metaLoaded, setMetaLoaded] = useState(false);
853
- const [data, setData] = useState<NetworkMapResponse | null>(null);
854
- const [error, setError] = useState<string | null>(null);
855
- const [loading, setLoading] = useState(true);
856
- const [viewport, setViewport] = useState<MapViewport>(WORLD_VIEWPORT);
857
- const [visibleSearch, setVisibleSearch] = useState("");
858
- const selectedAccountId = useSelectedAccountId(meta?.accounts);
859
- const mapRequestIdRef = useRef(0);
860
- const mapAbortControllerRef = useRef<AbortController | null>(null);
861
-
862
- const load = useCallback(
863
- (refresh = false) => {
864
- const requestId = mapRequestIdRef.current + 1;
865
- mapRequestIdRef.current = requestId;
866
- mapAbortControllerRef.current?.abort();
867
- const controller = new AbortController();
868
- mapAbortControllerRef.current = controller;
869
- setLoading(true);
870
- setError(null);
871
- fetchMap(type, refresh, selectedAccountId, controller.signal)
872
- .then((nextData) => {
873
- if (mapRequestIdRef.current !== requestId) return;
874
- setData(nextData);
875
- })
876
- .catch((cause: unknown) => {
877
- if (
878
- controller.signal.aborted ||
879
- mapRequestIdRef.current !== requestId
880
- ) {
881
- return;
882
- }
883
- setError(cause instanceof Error ? cause.message : "Map unavailable");
884
- })
885
- .finally(() => {
886
- if (mapRequestIdRef.current === requestId) {
887
- setLoading(false);
888
- }
889
- });
890
- },
891
- [selectedAccountId, type],
892
- );
893
-
894
- useEffect(() => {
895
- const controller = new AbortController();
896
- fetchQueryEnvelope({ signal: controller.signal })
897
- .then(setMeta)
898
- .catch(() => {
899
- // The map can still load against the default account if status is down.
900
- })
901
- .finally(() => {
902
- if (!controller.signal.aborted) setMetaLoaded(true);
903
- });
904
- return () => controller.abort();
905
- }, []);
906
-
907
- useEffect(() => {
908
- if (!metaLoaded) return;
909
- load(false);
910
- }, [load, metaLoaded]);
911
-
912
- useEffect(
913
- () => () => {
914
- mapAbortControllerRef.current?.abort();
915
- },
916
- [],
917
- );
918
-
919
- const visibleFeatures = useMemo(
920
- () =>
921
- (data?.features ?? [])
922
- .slice()
923
- .filter((feature) => boundsContainFeature(viewport.bounds, feature))
924
- .sort(
925
- (a, b) =>
926
- b.properties.followersCount - a.properties.followersCount ||
927
- a.properties.handle.localeCompare(b.properties.handle),
928
- ),
929
- [data, viewport],
930
- );
931
-
932
- const filteredVisibleFeatures = useMemo(
933
- () =>
934
- visibleFeatures
935
- .filter((feature) => featureMatchesSearch(feature, visibleSearch))
936
- .slice(0, 160),
937
- [visibleFeatures, visibleSearch],
938
- );
621
+ const {
622
+ type,
623
+ setType,
624
+ viewport,
625
+ setViewport,
626
+ visibleSearch,
627
+ setVisibleSearch,
628
+ data,
629
+ loading,
630
+ error,
631
+ refresh,
632
+ visibleFeatures,
633
+ filteredVisibleFeatures,
634
+ mapTypes,
635
+ } = useNetworkMapController();
939
636
 
940
637
  return (
941
638
  <section className="flex min-h-screen flex-col min-[1180px]:h-screen min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
@@ -953,7 +650,7 @@ function NetworkMapRoute() {
953
650
  <button
954
651
  className={secondaryButtonClass}
955
652
  type="button"
956
- onClick={() => load(true)}
653
+ onClick={() => refresh()}
957
654
  disabled={loading}
958
655
  >
959
656
  <RefreshCw className={cx("size-4", loading && "animate-spin")} />
@@ -963,7 +660,7 @@ function NetworkMapRoute() {
963
660
  </div>
964
661
  <div className="border-t border-[var(--line)] px-4 py-3">
965
662
  <div className={segmentedClass}>
966
- {MAP_TYPES.map((item) => (
663
+ {mapTypes.map((item) => (
967
664
  <button
968
665
  key={item.value}
969
666
  type="button"
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { useQuery } from "@tanstack/react-query";
2
3
  import {
3
4
  Activity,
4
5
  AlertTriangle,
@@ -7,7 +8,8 @@ import {
7
8
  RefreshCw,
8
9
  ShieldCheck,
9
10
  } from "lucide-react";
10
- import { useCallback, useEffect, useMemo, useState } from "react";
11
+ import { useMemo } from "react";
12
+ import { queryKeys } from "#/lib/query-client";
11
13
  import type {
12
14
  XurlRateLimitEndpointSnapshot,
13
15
  XurlRateLimitEvent,
@@ -192,26 +194,16 @@ function EventRow({
192
194
  }
193
195
 
194
196
  function RateLimitsRoute() {
195
- const [snapshot, setSnapshot] = useState<XurlRateLimitSnapshot | null>(null);
196
- const [error, setError] = useState<string | null>(null);
197
- const [loading, setLoading] = useState(true);
197
+ const rateLimitsQuery = useQuery({
198
+ queryKey: queryKeys.rateLimits,
199
+ queryFn: fetchRateLimits,
200
+ staleTime: 60_000,
201
+ });
202
+ const snapshot = rateLimitsQuery.data ?? null;
203
+ const loading = rateLimitsQuery.isFetching;
204
+ const error = rateLimitsQuery.error;
198
205
  const nowMs = useMemo(() => Date.now(), [snapshot]);
199
206
 
200
- const load = useCallback(() => {
201
- setLoading(true);
202
- setError(null);
203
- fetchRateLimits()
204
- .then(setSnapshot)
205
- .catch((cause: unknown) => {
206
- setError(cause instanceof Error ? cause.message : "Rate limits failed");
207
- })
208
- .finally(() => setLoading(false));
209
- }, []);
210
-
211
- useEffect(() => {
212
- load();
213
- }, [load]);
214
-
215
207
  return (
216
208
  <section className="flex min-h-screen flex-col">
217
209
  <header className={pageHeaderClass}>
@@ -238,7 +230,7 @@ function RateLimitsRoute() {
238
230
  <button
239
231
  className={secondaryButtonClass}
240
232
  disabled={loading}
241
- onClick={load}
233
+ onClick={() => void rateLimitsQuery.refetch()}
242
234
  type="button"
243
235
  >
244
236
  <RefreshCw
@@ -251,7 +243,11 @@ function RateLimitsRoute() {
251
243
  </div>
252
244
  </header>
253
245
 
254
- {error ? <div className={errorCopyClass}>{error}</div> : null}
246
+ {error ? (
247
+ <div className={errorCopyClass}>
248
+ {error instanceof Error ? error.message : "Rate limits failed"}
249
+ </div>
250
+ ) : null}
255
251
 
256
252
  {snapshot ? (
257
253
  <>
@@ -1,109 +0,0 @@
1
- const DEFAULT_MAX_ENTRIES = 100;
2
-
3
- interface ClientCacheEntry<T> {
4
- value: T;
5
- updatedAt: number;
6
- }
7
-
8
- interface LoadClientCacheOptions {
9
- force?: boolean;
10
- maxAgeMs?: number;
11
- }
12
-
13
- const values = new Map<string, ClientCacheEntry<unknown>>();
14
- const pending = new Map<string, Promise<unknown>>();
15
- const revisions = new Map<string, number>();
16
- let generation = 0;
17
-
18
- function cacheToken(key: string) {
19
- return `${String(generation)}:${String(revisions.get(key) ?? 0)}`;
20
- }
21
-
22
- function invalidateKey(key: string) {
23
- revisions.set(key, (revisions.get(key) ?? 0) + 1);
24
- }
25
-
26
- function pruneCache() {
27
- while (values.size > DEFAULT_MAX_ENTRIES) {
28
- const oldestKey = values.keys().next().value as string | undefined;
29
- if (!oldestKey) return;
30
- values.delete(oldestKey);
31
- }
32
- }
33
-
34
- export function readClientCache<T>(
35
- key: string,
36
- maxAgeMs = Number.POSITIVE_INFINITY,
37
- ) {
38
- const entry = values.get(key) as ClientCacheEntry<T> | undefined;
39
- if (!entry) return undefined;
40
- if (Date.now() - entry.updatedAt > maxAgeMs) {
41
- values.delete(key);
42
- return undefined;
43
- }
44
- return entry.value;
45
- }
46
-
47
- export function writeClientCache<T>(key: string, value: T) {
48
- values.delete(key);
49
- values.set(key, { value, updatedAt: Date.now() });
50
- pruneCache();
51
- return value;
52
- }
53
-
54
- export function loadClientCache<T>(
55
- key: string,
56
- load: () => Promise<T>,
57
- {
58
- force = false,
59
- maxAgeMs = Number.POSITIVE_INFINITY,
60
- }: LoadClientCacheOptions = {},
61
- ) {
62
- if (force) {
63
- invalidateKey(key);
64
- values.delete(key);
65
- pending.delete(key);
66
- } else {
67
- const cached = readClientCache<T>(key, maxAgeMs);
68
- if (cached !== undefined) return Promise.resolve(cached);
69
- }
70
-
71
- const active = pending.get(key) as Promise<T> | undefined;
72
- if (active) return active;
73
-
74
- const token = cacheToken(key);
75
- let request: Promise<T>;
76
- request = load()
77
- .then((value) => {
78
- if (cacheToken(key) === token) writeClientCache(key, value);
79
- return value;
80
- })
81
- .finally(() => {
82
- if (pending.get(key) === request) pending.delete(key);
83
- });
84
- pending.set(key, request);
85
- return request;
86
- }
87
-
88
- export function deleteClientCache(key: string) {
89
- invalidateKey(key);
90
- values.delete(key);
91
- pending.delete(key);
92
- }
93
-
94
- export function deleteClientCacheByPrefix(prefix: string) {
95
- const keys = new Set([...values.keys(), ...pending.keys()]);
96
- for (const key of keys) {
97
- if (!key.startsWith(prefix)) continue;
98
- invalidateKey(key);
99
- values.delete(key);
100
- pending.delete(key);
101
- }
102
- }
103
-
104
- export function clearClientCache() {
105
- generation += 1;
106
- values.clear();
107
- pending.clear();
108
- revisions.clear();
109
- }