Skip to content

Commit b303054

Browse files
committed
HDSSettings: dynamic settings with prefix pattern (autoConvert)
Add dynamic settings support for prefix-based keys stored as individual Pryv events. autoConvert-{itemKey} settings use event type settings/auto-convert with content { itemKey, method }. - setDynamic/getDynamic API for prefix-based settings - load() fetches both typed and dynamic event types - resolveObservationLabel: localized labels for converter results - get() checks dynamic values first, then typed - 10 new tests for dynamic settings CRUD + persistence
1 parent f80e093 commit b303054

3 files changed

Lines changed: 247 additions & 12 deletions

File tree

tests/HDSSettings.test.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,121 @@ describe('[STPS] SETTING_TYPES', () => {
202202
assert.strictEqual(SETTING_TYPES.displayName, 'contact/display-name');
203203
});
204204
});
205+
206+
describe('[HDSD] HDSSettings dynamic settings', function () {
207+
afterEach(() => {
208+
HDSSettings.unhook();
209+
});
210+
211+
it('[HDSD1] _testInject and get work for dynamic keys', () => {
212+
HDSSettings._testInject('autoConvert-wellbeing-mood', 'billings');
213+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), 'billings');
214+
assert.strictEqual(HDSSettings.isHooked, true);
215+
});
216+
217+
it('[HDSD2] _testClear removes dynamic key', () => {
218+
HDSSettings._testInject('autoConvert-wellbeing-mood', 'mira');
219+
HDSSettings._testClear('autoConvert-wellbeing-mood');
220+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), undefined);
221+
});
222+
223+
it('[HDSD3] getDynamic returns all settings with prefix', () => {
224+
HDSSettings._testInject('autoConvert-wellbeing-mood', 'billings');
225+
HDSSettings._testInject('autoConvert-body-vulva-mucus-inspect', 'appleHealth');
226+
const all = HDSSettings.getDynamic('autoConvert-');
227+
assert.strictEqual(all['wellbeing-mood'], 'billings');
228+
assert.strictEqual(all['body-vulva-mucus-inspect'], 'appleHealth');
229+
});
230+
231+
it('[HDSD4] unhook clears dynamic settings', () => {
232+
HDSSettings._testInject('autoConvert-wellbeing-mood', 'billings');
233+
HDSSettings.unhook();
234+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), undefined);
235+
assert.deepStrictEqual(HDSSettings.getDynamic('autoConvert-'), {});
236+
});
237+
238+
it('[HDSD5] load reads dynamic settings from server events', async () => {
239+
const conn = createMockConnection({
240+
'events.get': () => ({
241+
events: [
242+
{ id: 'ev-ac1', type: 'settings/auto-convert', content: { itemKey: 'wellbeing-mood', method: 'billings' } },
243+
{ id: 'ev-ac2', type: 'settings/auto-convert', content: { itemKey: 'body-vulva-mucus-inspect', method: 'appleHealth' } },
244+
{ id: 'ev-t', type: 'settings/theme', content: 'dark' },
245+
]
246+
})
247+
});
248+
await HDSSettings.hookToConnection(conn, 'test-stream');
249+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), 'billings');
250+
assert.strictEqual(HDSSettings.get('autoConvert-body-vulva-mucus-inspect'), 'appleHealth');
251+
assert.strictEqual(HDSSettings.get('theme'), 'dark');
252+
});
253+
254+
it('[HDSD6] setDynamic creates new event', async () => {
255+
const conn = createMockConnection();
256+
await HDSSettings.hookToConnection(conn, 'test-stream');
257+
258+
await HDSSettings.setDynamic('autoConvert-wellbeing-mood', 'billings');
259+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), 'billings');
260+
261+
const createCall = conn.apiCalls.find(c =>
262+
c.method === 'events.create' && c.params.type === 'settings/auto-convert'
263+
);
264+
assert.ok(createCall, 'Should have called events.create');
265+
assert.strictEqual(createCall.params.content.itemKey, 'wellbeing-mood');
266+
assert.strictEqual(createCall.params.content.method, 'billings');
267+
});
268+
269+
it('[HDSD7] setDynamic updates existing event', async () => {
270+
const conn = createMockConnection({
271+
'events.get': () => ({
272+
events: [
273+
{ id: 'ev-ac-mood', type: 'settings/auto-convert', content: { itemKey: 'wellbeing-mood', method: 'billings' } }
274+
]
275+
})
276+
});
277+
await HDSSettings.hookToConnection(conn, 'test-stream');
278+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), 'billings');
279+
280+
await HDSSettings.setDynamic('autoConvert-wellbeing-mood', 'mira');
281+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), 'mira');
282+
283+
const updateCall = conn.apiCalls.find(c => c.method === 'events.update');
284+
assert.ok(updateCall, 'Should have called events.update');
285+
assert.strictEqual(updateCall.params.id, 'ev-ac-mood');
286+
assert.strictEqual(updateCall.params.update.content.method, 'mira');
287+
});
288+
289+
it('[HDSD8] setDynamic with null deletes setting', async () => {
290+
const conn = createMockConnection({
291+
'events.get': () => ({
292+
events: [
293+
{ id: 'ev-ac-mood', type: 'settings/auto-convert', content: { itemKey: 'wellbeing-mood', method: 'billings' } }
294+
]
295+
})
296+
});
297+
await HDSSettings.hookToConnection(conn, 'test-stream');
298+
299+
await HDSSettings.setDynamic('autoConvert-wellbeing-mood', null);
300+
assert.strictEqual(HDSSettings.get('autoConvert-wellbeing-mood'), undefined);
301+
302+
const deleteCall = conn.apiCalls.find(c => c.method === 'events.delete');
303+
assert.ok(deleteCall, 'Should have called events.delete');
304+
assert.strictEqual(deleteCall.params.id, 'ev-ac-mood');
305+
});
306+
307+
it('[HDSD9] setDynamic throws for unknown prefix', async () => {
308+
const conn = createMockConnection();
309+
await HDSSettings.hookToConnection(conn, 'test-stream');
310+
await assert.rejects(
311+
() => HDSSettings.setDynamic('unknownPrefix-foo', 'bar'),
312+
/Unknown dynamic setting prefix/
313+
);
314+
});
315+
316+
it('[HDSD10] setDynamic throws when not hooked', async () => {
317+
await assert.rejects(
318+
() => HDSSettings.setDynamic('autoConvert-wellbeing-mood', 'billings'),
319+
/hookToApplication|hookToConnection/
320+
);
321+
});
322+
});

ts/HDSModel/eventToShortText.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ function formatConvertible (_event: any, content: any, itemDef: any, model: any)
273273
if (HDSSettings.isHooked && vectors) {
274274
try {
275275
const settingKey = `autoConvert-${itemDef.key}`;
276-
const targetMethod = HDSSettings.get(settingKey as any);
276+
const targetMethod = HDSSettings.get(settingKey);
277277
if (targetMethod && typeof targetMethod === 'string') {
278278
// Skip conversion if target method equals source method
279279
if (source?.key === targetMethod && sourceLabel) {

ts/settings/HDSSettings.ts

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export const SETTING_TYPES = {
1414
displayName: 'contact/display-name',
1515
} as const;
1616

17+
/**
18+
* Dynamic setting prefixes — one event per key, keyed by content.itemKey.
19+
* Event type is shared for all settings with the same prefix.
20+
*/
21+
const DYNAMIC_PREFIXES: Record<string, { eventType: string; contentKey: string; contentValue: string }> = {
22+
'autoConvert-': { eventType: 'settings/auto-convert', contentKey: 'itemKey', contentValue: 'method' },
23+
};
24+
1725
export type SettingKey = keyof typeof SETTING_TYPES;
1826

1927
export type DateFormat = 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
@@ -70,6 +78,16 @@ function applySideEffects (values: SettingsValues, key: SettingKey): void {
7078
}
7179
}
7280

81+
/** Find the dynamic prefix config for a key, or null */
82+
function findDynamicPrefix (key: string): { prefix: string; eventType: string; contentKey: string; contentValue: string; suffix: string } | null {
83+
for (const [prefix, config] of Object.entries(DYNAMIC_PREFIXES)) {
84+
if (key.startsWith(prefix)) {
85+
return { prefix, ...config, suffix: key.slice(prefix.length) };
86+
}
87+
}
88+
return null;
89+
}
90+
7391
/** @internal */
7492
let _connection: pryv.Connection | null = null;
7593
/** @internal */
@@ -78,6 +96,10 @@ let _streamId: string | null = null;
7896
let _cache: Partial<Record<SettingKey, any>> = {};
7997
/** @internal */
8098
let _values: SettingsValues = { ...DEFAULTS };
99+
/** @internal — dynamic settings: key → value */
100+
let _dynamicValues: Record<string, any> = {};
101+
/** @internal — dynamic settings: key → cached event */
102+
let _dynamicCache: Record<string, any> = {};
81103
/** @internal */
82104
let _hooked = false;
83105

@@ -87,18 +109,38 @@ async function load (): Promise<void> {
87109
const browser = browserDefaults();
88110
_values = { ...DEFAULTS, ...browser };
89111
_cache = {};
112+
_dynamicValues = {};
113+
_dynamicCache = {};
114+
115+
// Collect all event types to fetch (typed + dynamic)
116+
const typedEventTypes = Object.values(SETTING_TYPES) as string[];
117+
const dynamicEventTypes = Object.values(DYNAMIC_PREFIXES).map(c => c.eventType);
118+
const allTypes = [...typedEventTypes, ...dynamicEventTypes];
90119

91120
const settingsEvents: any[] = await _connection.apiOne(
92121
'events.get',
93-
{ streams: [_streamId], types: Object.values(SETTING_TYPES), limit: 100 },
122+
{ streams: [_streamId], types: allTypes, limit: 200 },
94123
'events'
95124
);
96125

97126
for (const event of settingsEvents) {
127+
// Try typed settings first
98128
const key = keyForType(event.type);
99129
if (key && !_cache[key]) {
100130
_cache[key] = event;
101131
(_values as any)[key] = event.content;
132+
continue;
133+
}
134+
135+
// Try dynamic settings
136+
for (const [prefix, config] of Object.entries(DYNAMIC_PREFIXES)) {
137+
if (event.type === config.eventType && event.content?.[config.contentKey]) {
138+
const dynKey = prefix + event.content[config.contentKey];
139+
if (!_dynamicCache[dynKey]) {
140+
_dynamicCache[dynKey] = event;
141+
_dynamicValues[dynKey] = event.content[config.contentValue];
142+
}
143+
}
102144
}
103145
}
104146

@@ -109,13 +151,16 @@ async function load (): Promise<void> {
109151
/**
110152
* HDSSettings — singleton managing user settings as individual Pryv events.
111153
*
112-
* Each setting is stored as its own event with a specific type
113-
* (e.g. `settings/preferredLocales`) in the application's baseStream.
154+
* Supports two kinds of settings:
155+
* - **Typed settings**: fixed keys (theme, dateFormat, etc.) with specific event types.
156+
* - **Dynamic settings**: prefix-based keys (autoConvert-{itemKey}) stored as events
157+
* with a shared event type and keyed by content field.
114158
*
115159
* Usage:
116160
* await HDSSettings.hookToApplication(app);
117161
* const locale = HDSSettings.get('preferredLocales');
118162
* await HDSSettings.set('theme', 'dark');
163+
* await HDSSettings.setDynamic('autoConvert-wellbeing-mood', 'billings');
119164
*/
120165
const HDSSettings = {
121166

@@ -139,21 +184,37 @@ const HDSSettings = {
139184
},
140185

141186
/**
142-
* Get the current value for a setting.
187+
* Get the current value for a typed setting.
188+
* Also checks dynamic settings for prefix-based keys (e.g. 'autoConvert-wellbeing-mood').
143189
*/
144-
get<K extends SettingKey> (key: K): SettingsValues[K] {
145-
return _values[key];
190+
get (key: string): any {
191+
if (key in _dynamicValues) return _dynamicValues[key];
192+
return (_values as any)[key];
146193
},
147194

148195
/**
149-
* Get all current settings values.
196+
* Get all current typed settings values.
150197
*/
151198
getAll (): Readonly<SettingsValues> {
152199
return { ..._values };
153200
},
154201

155202
/**
156-
* Set a setting value — persists to HDS server and updates cache.
203+
* Get all dynamic settings with a given prefix.
204+
* Returns a map of suffix → value (e.g. { 'wellbeing-mood': 'billings' }).
205+
*/
206+
getDynamic (prefix: string): Record<string, any> {
207+
const result: Record<string, any> = {};
208+
for (const [key, value] of Object.entries(_dynamicValues)) {
209+
if (key.startsWith(prefix)) {
210+
result[key.slice(prefix.length)] = value;
211+
}
212+
}
213+
return result;
214+
},
215+
216+
/**
217+
* Set a typed setting value — persists to HDS server and updates cache.
157218
*/
158219
async set<K extends SettingKey> (key: K, value: SettingsValues[K]): Promise<void> {
159220
if (!_connection || !_streamId) {
@@ -183,6 +244,52 @@ const HDSSettings = {
183244
applySideEffects(_values, key);
184245
},
185246

247+
/**
248+
* Set a dynamic setting value — persists to HDS server.
249+
* Key must match a known prefix (e.g. 'autoConvert-wellbeing-mood').
250+
* Pass null to delete the setting.
251+
*/
252+
async setDynamic (key: string, value: any): Promise<void> {
253+
if (!_connection || !_streamId) {
254+
throw new Error('HDSSettings: call hookToApplication() or hookToConnection() first');
255+
}
256+
257+
const dp = findDynamicPrefix(key);
258+
if (!dp) throw new Error(`Unknown dynamic setting prefix for key: "${key}"`);
259+
260+
const existing = _dynamicCache[key];
261+
262+
if (value === null || value === undefined) {
263+
// Delete
264+
if (existing) {
265+
await _connection.apiOne('events.delete', { id: existing.id }, 'eventDeletion');
266+
delete _dynamicCache[key];
267+
delete _dynamicValues[key];
268+
}
269+
return;
270+
}
271+
272+
const content = { [dp.contentKey]: dp.suffix, [dp.contentValue]: value };
273+
274+
if (existing) {
275+
const updated = await _connection.apiOne(
276+
'events.update',
277+
{ id: existing.id, update: { content } },
278+
'event'
279+
);
280+
_dynamicCache[key] = updated;
281+
} else {
282+
const created = await _connection.apiOne(
283+
'events.create',
284+
{ streamIds: [_streamId], type: dp.eventType, content },
285+
'event'
286+
);
287+
_dynamicCache[key] = created;
288+
}
289+
290+
_dynamicValues[key] = value;
291+
},
292+
186293
/**
187294
* Whether settings have been loaded from the server.
188295
*/
@@ -205,23 +312,33 @@ const HDSSettings = {
205312
_streamId = null;
206313
_cache = {};
207314
_values = { ...DEFAULTS };
315+
_dynamicValues = {};
316+
_dynamicCache = {};
208317
_hooked = false;
209318
},
210319

211320
/**
212321
* @internal Test-only: inject a setting value and mark as hooked.
213-
* Allows testing code paths that depend on HDSSettings without a Pryv connection.
322+
* Works for both typed and dynamic keys.
214323
*/
215324
_testInject (key: string, value: any): void {
216-
(_values as any)[key] = value;
325+
if (findDynamicPrefix(key)) {
326+
_dynamicValues[key] = value;
327+
} else {
328+
(_values as any)[key] = value;
329+
}
217330
_hooked = true;
218331
},
219332

220333
/**
221334
* @internal Test-only: remove an injected setting.
222335
*/
223336
_testClear (key: string): void {
224-
delete (_values as any)[key];
337+
if (findDynamicPrefix(key)) {
338+
delete _dynamicValues[key];
339+
} else {
340+
delete (_values as any)[key];
341+
}
225342
},
226343
};
227344

0 commit comments

Comments
 (0)