@rimori/client 2.5.56 → 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 +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin/module/PluginModule.d.ts +4 -14
- package/dist/plugin/module/PluginModule.js +41 -48
- package/dist/utils/environment.d.ts +13 -0
- package/dist/utils/environment.js +22 -0
- package/dist/utils/environment.test.d.ts +1 -0
- package/dist/utils/environment.test.js +18 -0
- package/package.json +1 -1
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
|
|
60
|
-
* writer already created the row
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
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
|
|
154
|
-
* writer already created the row
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
204
|
-
//
|
|
205
|
-
// and
|
|
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
|
+
});
|