@rimori/client 2.5.57-next.0 → 2.5.57

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.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './cli/types/DatabaseTypes';
7
7
  export * from './cli/types/PromptTypes';
8
8
  export * from './plugin/TTS/MessageSender';
9
9
  export * from './utils/difficultyConverter';
10
+ export { resolveEnvironment, type RimoriEnvironment } from './utils/environment';
10
11
  export * from './plugin/CommunicationHandler';
11
12
  export type { TOptions } from 'i18next';
12
13
  export { setupWorker } from './worker/WorkerSetup';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from './cli/types/DatabaseTypes';
8
8
  export * from './cli/types/PromptTypes';
9
9
  export * from './plugin/TTS/MessageSender';
10
10
  export * from './utils/difficultyConverter';
11
+ export { resolveEnvironment } from './utils/environment';
11
12
  export * from './plugin/CommunicationHandler';
12
13
  export { setupWorker } from './worker/WorkerSetup';
13
14
  export { AudioController } from './controller/AudioController';
@@ -56,10 +56,10 @@ export declare class PluginModule {
56
56
  setSettings(settings: any): Promise<void>;
57
57
  /**
58
58
  * Seeds the plugin's default settings row without ever clobbering an existing one.
59
- * Used by getSettings when no row was read. Inserts the defaults; if a concurrent
60
- * writer already created the row (unique-violation) — or it existed all along and
61
- * the read merely missed it — re-reads and returns the persisted settings instead
62
- * of the defaults. This keeps the read-triggered seed idempotent and race-safe.
59
+ * Used by getSettings when no row was read. Inserts the defaults if absent; if a
60
+ * concurrent writer already created the row — or it existed all along and the read
61
+ * merely missed it — re-reads and returns the persisted settings instead of the
62
+ * defaults. This keeps the read-triggered seed idempotent and race-safe.
63
63
  */
64
64
  private seedDefaultSettings;
65
65
  /**
@@ -145,8 +145,6 @@ export type SubscriptionTier = 'anonymous' | 'free' | 'standard' | 'premium' | '
145
145
  export declare const TIER_ORDER: SubscriptionTier[];
146
146
  /** Ordered roles from lowest to highest access level */
147
147
  export declare const ROLE_ORDER: UserRole[];
148
- export declare const LEARNING_REASONS: readonly ["work", "partner", "friends", "study", "living", "culture", "growth", "citizenship", "other"];
149
- export type LearningReason = (typeof LEARNING_REASONS)[number];
150
148
  export type ExplicitUndefined<T> = {
151
149
  [K in Exclude<keyof T, never>]-?: {} extends Pick<T, K> ? T[K] | undefined : T[K];
152
150
  };
@@ -177,14 +175,6 @@ export interface UserInfo {
177
175
  * The language the user targets to learn.
178
176
  */
179
177
  target_language: Language;
180
- /**
181
- * Why the user is learning the language
182
- */
183
- learning_reason: LearningReason;
184
- /**
185
- * Free-text personal interests
186
- */
187
- personal_interests: string;
188
178
  onboarding_completed: boolean;
189
179
  context_menu_on_select: boolean;
190
180
  user_name?: string;
@@ -101,10 +101,10 @@ export class PluginModule {
101
101
  settings,
102
102
  guild_id: this.rimoriInfo.guild.id,
103
103
  is_guild_setting: isGuildSetting,
104
+ // Explicit so the value is also the ON CONFLICT arbiter key on the upsert
105
+ // fallback below. For user settings this equals the column default auth.uid().
106
+ user_id: isGuildSetting ? null : this.rimoriInfo.profile.user_id,
104
107
  };
105
- if (isGuildSetting) {
106
- payload.user_id = null;
107
- }
108
108
  // Try UPDATE first (safe with RLS). If nothing updated, INSERT.
109
109
  const updateQuery = this.supabase
110
110
  .schema('public')
@@ -127,33 +127,30 @@ export class PluginModule {
127
127
  if (updatedRows && updatedRows.length > 0) {
128
128
  return; // updated successfully
129
129
  }
130
- // No row updated -> INSERT
131
- const { error: insertError } = await this.supabase.schema('public').from('plugin_settings').insert(payload);
130
+ // No row updated -> INSERT. upsert (INSERT ... ON CONFLICT DO UPDATE) instead
131
+ // of a bare insert so a concurrent first-time writer that beat us to the row
132
+ // doesn't turn this into a 23505 / HTTP 409 in the browser console. The
133
+ // onConflict key names the non-partial ux_plugin_settings_guild_plugin_user_nnd
134
+ // index — a partial index can't be an ON CONFLICT arbiter without its predicate.
135
+ const { error: insertError } = await this.supabase
136
+ .schema('public')
137
+ .from('plugin_settings')
138
+ .upsert(payload, { onConflict: 'guild_id,plugin_id,user_id' });
132
139
  if (insertError) {
133
- // In case of race condition (duplicate), try one more UPDATE
134
- if (insertError.code === '23505' /* unique_violation */) {
135
- const retry = this.supabase
136
- .schema('public')
137
- .from('plugin_settings')
138
- .update({ settings })
139
- .eq('plugin_id', this.pluginId)
140
- .eq('guild_id', this.rimoriInfo.guild.id)
141
- .eq('is_guild_setting', isGuildSetting);
142
- const { error: retryError } = await (isGuildSetting ? retry.is('user_id', null) : retry);
143
- if (!retryError)
144
- return;
145
- }
146
140
  // Write failed — drop the optimistic cache so reads refetch the truth.
147
141
  this.settingsPromise = undefined;
142
+ if (insertError.code === '42501' || insertError.message?.includes('policy')) {
143
+ throw new Error(`Cannot set ${isGuildSetting ? 'guild' : 'user'} settings: Permission denied.`);
144
+ }
148
145
  throw insertError;
149
146
  }
150
147
  }
151
148
  /**
152
149
  * Seeds the plugin's default settings row without ever clobbering an existing one.
153
- * Used by getSettings when no row was read. Inserts the defaults; if a concurrent
154
- * writer already created the row (unique-violation) — or it existed all along and
155
- * the read merely missed it — re-reads and returns the persisted settings instead
156
- * of the defaults. This keeps the read-triggered seed idempotent and race-safe.
150
+ * Used by getSettings when no row was read. Inserts the defaults if absent; if a
151
+ * concurrent writer already created the row — or it existed all along and the read
152
+ * merely missed it — re-reads and returns the persisted settings instead of the
153
+ * defaults. This keeps the read-triggered seed idempotent and race-safe.
157
154
  */
158
155
  async seedDefaultSettings(defaults) {
159
156
  const isGuildSetting = !this.rimoriInfo.guild.allowUserPluginSettings;
@@ -162,20 +159,27 @@ export class PluginModule {
162
159
  settings: defaults,
163
160
  guild_id: this.rimoriInfo.guild.id,
164
161
  is_guild_setting: isGuildSetting,
162
+ // Explicit so it is also the ON CONFLICT arbiter key. For user settings this
163
+ // equals the column default auth.uid().
164
+ user_id: isGuildSetting ? null : this.rimoriInfo.profile.user_id,
165
165
  };
166
- if (isGuildSetting) {
167
- payload.user_id = null;
168
- }
169
- const { error } = await this.supabase.schema('public').from('plugin_settings').insert(payload);
166
+ // upsert with ignoreDuplicates => INSERT ... ON CONFLICT DO NOTHING, which
167
+ // succeeds for BOTH racers: the loser gets a 204, not a 23505 / HTTP 409 in
168
+ // the console. During first onboarding the federated main panel and the plugin
169
+ // worker run independent Supabase clients with independent single-flight caches
170
+ // and both reach this branch before any row exists. onConflict names the
171
+ // non-partial ux_plugin_settings_guild_plugin_user_nnd index — a partial index
172
+ // cannot be an ON CONFLICT arbiter without its predicate, which PostgREST
173
+ // cannot send.
174
+ const { error } = await this.supabase
175
+ .schema('public')
176
+ .from('plugin_settings')
177
+ .upsert(payload, { onConflict: 'guild_id,plugin_id,user_id', ignoreDuplicates: true });
170
178
  if (!error) {
171
- // Keep the read cache consistent with what we just persisted.
172
- this.settingsPromise = Promise.resolve(defaults);
173
- return defaults;
174
- }
175
- // Row already exists (another instance won the race, or the earlier read missed
176
- // it). Adopt the persisted row rather than overwriting it with our defaults.
177
- if (error.code === '23505' /* unique_violation */) {
178
- this.settingsPromise = undefined; // force a fresh refetch
179
+ // Our defaults may or may not be the row that landed. Re-read the
180
+ // authoritative row rather than assuming we won — a concurrent writer may
181
+ // have already persisted richer state (e.g. is_inited:true).
182
+ this.settingsPromise = undefined;
179
183
  const existing = await this.fetchSettings();
180
184
  return existing ?? defaults;
181
185
  }
@@ -200,9 +204,9 @@ export class PluginModule {
200
204
  // runtime with its own client + single-flight cache and so doesn't share the
201
205
  // in-flight dedupe above. They all reach this null branch before any row exists
202
206
  // and race to create it. An unconditional write here both 409s on the unique
203
- // index (ux_plugin_settings_guild_plugin_user) and lets a loser clobber a
204
- // freshly-persisted row (e.g. is_inited:true) back to defaults. Insert-if-absent
205
- // and adopt the existing row on conflict — this also guards against a transient
207
+ // index and lets a loser clobber a freshly-persisted row (e.g. is_inited:true)
208
+ // back to defaults. seedDefaultSettings does INSERT ... ON CONFLICT DO NOTHING
209
+ // and adopts the existing row on conflict — this also guards against a transient
206
210
  // read miss (fetchSettings swallows errors → null) overwriting good data.
207
211
  return (await this.seedDefaultSettings(defaultSettings));
208
212
  }
@@ -289,14 +293,3 @@ export class PluginModule {
289
293
  export const TIER_ORDER = ['anonymous', 'free', 'standard', 'premium', 'early_access'];
290
294
  /** Ordered roles from lowest to highest access level */
291
295
  export const ROLE_ORDER = ['user', 'plugin_moderator', 'lang_moderator', 'admin'];
292
- export const LEARNING_REASONS = [
293
- 'work',
294
- 'partner',
295
- 'friends',
296
- 'study',
297
- 'living',
298
- 'culture',
299
- 'growth',
300
- 'citizenship',
301
- 'other',
302
- ];
@@ -0,0 +1,13 @@
1
+ export type RimoriEnvironment = 'prod' | 'dev' | 'local';
2
+ /**
3
+ * Resolves the deployment environment from the hostname.
4
+ *
5
+ * `import.meta.env.DEV` only distinguishes a local dev build from anything deployed — it cannot
6
+ * tell prod (`app.rimori.se`) apart from the deployed dev stack (`dev-app.rimori.se`), so events
7
+ * from both used to land in the same bucket. The hostname is the authoritative signal; it is also
8
+ * what VoiceDebugStore's isProdHost() uses, and federated plugins share rimori-main's window, so
9
+ * plugin code sees the host's hostname here too.
10
+ *
11
+ * `local` is the "do not report" answer: callers must skip PostHog init entirely for it.
12
+ */
13
+ export declare function resolveEnvironment(hostname?: string): RimoriEnvironment;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolves the deployment environment from the hostname.
3
+ *
4
+ * `import.meta.env.DEV` only distinguishes a local dev build from anything deployed — it cannot
5
+ * tell prod (`app.rimori.se`) apart from the deployed dev stack (`dev-app.rimori.se`), so events
6
+ * from both used to land in the same bucket. The hostname is the authoritative signal; it is also
7
+ * what VoiceDebugStore's isProdHost() uses, and federated plugins share rimori-main's window, so
8
+ * plugin code sees the host's hostname here too.
9
+ *
10
+ * `local` is the "do not report" answer: callers must skip PostHog init entirely for it.
11
+ */
12
+ export function resolveEnvironment(hostname) {
13
+ const host = hostname ?? (typeof window !== 'undefined' ? window.location.hostname : '');
14
+ if (host === 'app.rimori.se' || host === 'rimori.se' || host === 'www.rimori.se')
15
+ return 'prod';
16
+ if (host === 'dev-app.rimori.se' || host === 'dev.rimori.se')
17
+ return 'dev';
18
+ // Other *.rimori.se hosts are deployed preview/dev surfaces, never a developer machine.
19
+ if (host.endsWith('.rimori.se'))
20
+ return 'dev';
21
+ return 'local';
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveEnvironment } from './environment';
3
+ describe('resolveEnvironment', () => {
4
+ it.each([
5
+ ['app.rimori.se', 'prod'],
6
+ ['rimori.se', 'prod'],
7
+ ['www.rimori.se', 'prod'],
8
+ ['dev-app.rimori.se', 'dev'],
9
+ ['dev.rimori.se', 'dev'],
10
+ // Any other deployed *.rimori.se surface is a dev surface, never a developer machine.
11
+ ['preview.rimori.se', 'dev'],
12
+ ['localhost', 'local'],
13
+ ['127.0.0.1', 'local'],
14
+ ['', 'local'],
15
+ ])('maps %s to %s', (hostname, expected) => {
16
+ expect(resolveEnvironment(hostname)).toBe(expected);
17
+ });
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/client",
3
- "version": "2.5.57-next.0",
3
+ "version": "2.5.57",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {