@rimori/client 2.5.43-next.0 → 2.5.44-next.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.
@@ -30,6 +30,14 @@ export declare class PluginModule {
30
30
  * Updated automatically when the user toggles TTS.
31
31
  */
32
32
  ttsEnabled: boolean;
33
+ /**
34
+ * Single-flight cache for the plugin settings row. Holds the in-flight (or
35
+ * resolved) promise, not the value, so concurrent getSettings() callers join
36
+ * one GET instead of each issuing their own.
37
+ */
38
+ private settingsPromise?;
39
+ /** Key-count migration runs at most once per plugin lifetime, not per read. */
40
+ private migrationDone;
33
41
  constructor(supabase: SupabaseClient, communicationHandler: RimoriCommunicationHandler, info: RimoriInfo, ai: AIModule, eventBus: EventBusHandler);
34
42
  /**
35
43
  * Fetches settings based on guild configuration.
@@ -22,6 +22,14 @@ export class PluginModule {
22
22
  * Updated automatically when the user toggles TTS.
23
23
  */
24
24
  ttsEnabled = true;
25
+ /**
26
+ * Single-flight cache for the plugin settings row. Holds the in-flight (or
27
+ * resolved) promise, not the value, so concurrent getSettings() callers join
28
+ * one GET instead of each issuing their own.
29
+ */
30
+ settingsPromise;
31
+ /** Key-count migration runs at most once per plugin lifetime, not per read. */
32
+ migrationDone = false;
25
33
  constructor(supabase, communicationHandler, info, ai, eventBus) {
26
34
  this.rimoriInfo = info;
27
35
  this.supabase = supabase;
@@ -33,6 +41,12 @@ export class PluginModule {
33
41
  this.translator = new Translator(info.interfaceLanguage, currentPlugin?.endpoint || '', ai);
34
42
  this.ttsEnabled = info.ttsEnabled ?? true;
35
43
  this.communicationHandler.onUpdate((updatedInfo) => {
44
+ // Settings are keyed by guild id — bust the cache if the guild changed so
45
+ // we don't serve another guild's settings from a stale promise.
46
+ if (updatedInfo.guild.id !== this.rimoriInfo.guild.id) {
47
+ this.settingsPromise = undefined;
48
+ this.migrationDone = false;
49
+ }
36
50
  this.rimoriInfo = updatedInfo;
37
51
  this.ttsEnabled = updatedInfo.ttsEnabled ?? true;
38
52
  });
@@ -46,16 +60,28 @@ export class PluginModule {
46
60
  * @returns The settings object or null if not found.
47
61
  */
48
62
  async fetchSettings() {
49
- const isGuildSetting = !this.rimoriInfo.guild.allowUserPluginSettings;
50
- const { data } = await this.supabase
51
- .schema('public')
52
- .from('plugin_settings')
53
- .select('*')
54
- .eq('plugin_id', this.pluginId)
55
- .eq('guild_id', this.rimoriInfo.guild.id)
56
- .eq('is_guild_setting', isGuildSetting)
57
- .maybeSingle();
58
- return data?.settings ?? null;
63
+ // Join the in-flight request if one already exists.
64
+ if (this.settingsPromise)
65
+ return this.settingsPromise;
66
+ const promise = (async () => {
67
+ const isGuildSetting = !this.rimoriInfo.guild.allowUserPluginSettings;
68
+ const { data } = await this.supabase
69
+ .schema('public')
70
+ .from('plugin_settings')
71
+ .select('*')
72
+ .eq('plugin_id', this.pluginId)
73
+ .eq('guild_id', this.rimoriInfo.guild.id)
74
+ .eq('is_guild_setting', isGuildSetting)
75
+ .maybeSingle();
76
+ return (data?.settings ?? null);
77
+ })();
78
+ this.settingsPromise = promise;
79
+ // Don't poison the cache: drop a failed fetch so the next caller retries.
80
+ promise.catch(() => {
81
+ if (this.settingsPromise === promise)
82
+ this.settingsPromise = undefined;
83
+ });
84
+ return promise;
59
85
  }
60
86
  /**
61
87
  * Sets settings for the plugin.
@@ -65,6 +91,8 @@ export class PluginModule {
65
91
  * @throws {Error} if RLS blocks the operation.
66
92
  */
67
93
  async setSettings(settings) {
94
+ // Keep the read cache consistent with what we're about to persist.
95
+ this.settingsPromise = Promise.resolve(settings);
68
96
  const isGuildSetting = !this.rimoriInfo.guild.allowUserPluginSettings;
69
97
  const payload = {
70
98
  plugin_id: this.pluginId,
@@ -88,6 +116,8 @@ export class PluginModule {
88
116
  : updateQuery.select());
89
117
  if (updateError) {
90
118
  if (updateError.code === '42501' || updateError.message?.includes('policy')) {
119
+ // Write failed — drop the optimistic cache so reads refetch the truth.
120
+ this.settingsPromise = undefined;
91
121
  throw new Error(`Cannot set ${isGuildSetting ? 'guild' : 'user'} settings: Permission denied.`);
92
122
  }
93
123
  // proceed to try insert in case of other issues
@@ -111,6 +141,8 @@ export class PluginModule {
111
141
  if (!retryError)
112
142
  return;
113
143
  }
144
+ // Write failed — drop the optimistic cache so reads refetch the truth.
145
+ this.settingsPromise = undefined;
114
146
  throw insertError;
115
147
  }
116
148
  }
@@ -125,14 +157,19 @@ export class PluginModule {
125
157
  await this.setSettings(defaultSettings);
126
158
  return defaultSettings;
127
159
  }
128
- // Handle settings migration
129
- const storedKeys = Object.keys(storedSettings);
130
- const defaultKeys = Object.keys(defaultSettings);
131
- if (storedKeys.length !== defaultKeys.length) {
132
- const validStoredSettings = Object.fromEntries(Object.entries(storedSettings).filter(([key]) => defaultKeys.includes(key)));
133
- const mergedSettings = { ...defaultSettings, ...validStoredSettings };
134
- await this.setSettings(mergedSettings);
135
- return mergedSettings;
160
+ // Key-count migration runs at most once per plugin lifetime. Concurrent callers
161
+ // share the fetchSettings promise; the first to resume here flips the flag (a
162
+ // synchronous step before the next await), so the rest skip the redundant PATCH.
163
+ if (!this.migrationDone) {
164
+ this.migrationDone = true;
165
+ const storedKeys = Object.keys(storedSettings);
166
+ const defaultKeys = Object.keys(defaultSettings);
167
+ if (storedKeys.length !== defaultKeys.length) {
168
+ const validStoredSettings = Object.fromEntries(Object.entries(storedSettings).filter(([key]) => defaultKeys.includes(key)));
169
+ const mergedSettings = { ...defaultSettings, ...validStoredSettings };
170
+ await this.setSettings(mergedSettings);
171
+ return mergedSettings;
172
+ }
136
173
  }
137
174
  return storedSettings;
138
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/client",
3
- "version": "2.5.43-next.0",
3
+ "version": "2.5.44-next.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {