diff --git a/core/src/sessions/in_memory_session_service.ts b/core/src/sessions/in_memory_session_service.ts index e521f2fb3..fa19f8bd4 100644 --- a/core/src/sessions/in_memory_session_service.ts +++ b/core/src/sessions/in_memory_session_service.ts @@ -18,10 +18,9 @@ import { ListSessionsRequest, ListSessionsResponse, mergeStates, - trimTempState, } from './base_session_service.js'; import {createSession, Session} from './session.js'; -import {extractStateDelta} from './state_utils.js'; +import {extractStateDelta, StateDeltas} from './state_utils.js'; /** * Checks if the given URI is an in-memory memory service URI. @@ -52,18 +51,45 @@ export class InMemorySessionService extends BaseSessionService { */ private appState: Record> = {}; + /** + * Merges app- and user-scoped deltas into the stores shared across sessions. + * + * Empty deltas are skipped so that apps and users which never set scoped + * state gain no entry at all. + */ + private applyScopedDeltas( + appName: string, + userId: string, + {app, user}: StateDeltas, + ): void { + if (Object.keys(app).length > 0) { + this.appState[appName] = {...this.appState[appName], ...app}; + } + + if (Object.keys(user).length > 0) { + this.userState[appName] = this.userState[appName] || {}; + this.userState[appName][userId] = { + ...this.userState[appName][userId], + ...user, + }; + } + } + async createSession({ appName, userId, state, sessionId, }: CreateSessionRequest): Promise { - const filteredState = state ? trimTempState(state) : undefined; + const deltas = extractStateDelta(state); + // Must precede the mergeStates() below, which re-reads the scope stores. + this.applyScopedDeltas(appName, userId, deltas); + const session = createSession({ id: sessionId || randomUUID(), appName, userId, - state: filteredState, + state: deltas.session, events: [], lastUpdateTime: Date.now(), }); @@ -275,21 +301,11 @@ export class InMemorySessionService extends BaseSessionService { if (event.actions && event.actions.stateDelta) { // The session bucket is deliberately ignored: session state is applied by // super.appendEvent(), which keeps the `app:`/`user:` prefixes on the key. - const {app: appDelta, user: userDelta} = extractStateDelta( - event.actions.stateDelta, + this.applyScopedDeltas( + appName, + userId, + extractStateDelta(event.actions.stateDelta), ); - - if (Object.keys(appDelta).length > 0) { - this.appState[appName] = {...this.appState[appName], ...appDelta}; - } - - if (Object.keys(userDelta).length > 0) { - this.userState[appName] = this.userState[appName] || {}; - this.userState[appName][userId] = { - ...this.userState[appName][userId], - ...userDelta, - }; - } } const storageSession: Session = this.sessions[appName][userId][sessionId]; diff --git a/core/test/sessions/in_memory_session_service_test.ts b/core/test/sessions/in_memory_session_service_test.ts index 64f54119c..56316e6c7 100644 --- a/core/test/sessions/in_memory_session_service_test.ts +++ b/core/test/sessions/in_memory_session_service_test.ts @@ -100,6 +100,141 @@ describe('InMemorySessionService', () => { expect(session.state).toHaveProperty('normalKey', 'value'); expect(session.state).not.toHaveProperty(`${State.TEMP_PREFIX}tempKey`); }); + + it('shares app: state from initial state with other sessions of the same app', async () => { + const appName = 'app'; + await service.createSession({ + appName, + userId: 'user1', + state: {[`${State.APP_PREFIX}config`]: 'dark-mode'}, + }); + + const session2 = await service.createSession({appName, userId: 'user2'}); + + expect(session2.state).toHaveProperty( + `${State.APP_PREFIX}config`, + 'dark-mode', + ); + }); + + it('shares user: state from initial state with other sessions of the same user', async () => { + const appName = 'app'; + const userId = 'user1'; + await service.createSession({ + appName, + userId, + state: {[`${State.USER_PREFIX}pref`]: 'A'}, + }); + + const session2 = await service.createSession({appName, userId}); + + expect(session2.state).toHaveProperty(`${State.USER_PREFIX}pref`, 'A'); + }); + + it('does not share user: state from initial state with a different user', async () => { + const appName = 'app'; + await service.createSession({ + appName, + userId: 'user1', + state: {[`${State.USER_PREFIX}pref`]: 'A'}, + }); + + const session2 = await service.createSession({appName, userId: 'user2'}); + + expect(session2.state).not.toHaveProperty(`${State.USER_PREFIX}pref`); + }); + + it('keeps unprefixed initial state scoped to the session that created it', async () => { + const appName = 'app'; + const userId = 'user'; + const session1 = await service.createSession({ + appName, + userId, + state: {sessionKey: 'value'}, + }); + + const session2 = await service.createSession({appName, userId}); + const retrieved = await service.getSession({ + appName, + userId, + sessionId: session1.id, + }); + + expect(retrieved?.state).toEqual({sessionKey: 'value'}); + expect(session2.state).not.toHaveProperty('sessionKey'); + }); + + it('drops temp: keys from initial state instead of promoting them', async () => { + const appName = 'app'; + const session1 = await service.createSession({ + appName, + userId: 'user1', + state: { + [`${State.TEMP_PREFIX}scratch`]: 'dropped', + [`${State.APP_PREFIX}kept`]: 1, + }, + }); + + const session2 = await service.createSession({appName, userId: 'user2'}); + + expect(session1.state).toEqual({[`${State.APP_PREFIX}kept`]: 1}); + expect(session2.state).toEqual({[`${State.APP_PREFIX}kept`]: 1}); + }); + + it('lets app: initial state overwrite an existing app value', async () => { + const appName = 'app'; + const userId = 'user'; + const session1 = await service.createSession({ + appName, + userId, + state: {[`${State.APP_PREFIX}config`]: 'dark-mode'}, + }); + + const session2 = await service.createSession({ + appName, + userId, + state: {[`${State.APP_PREFIX}config`]: 'light-mode'}, + }); + const retrieved = await service.getSession({ + appName, + userId, + sessionId: session1.id, + }); + + expect(session2.state).toHaveProperty( + `${State.APP_PREFIX}config`, + 'light-mode', + ); + expect(retrieved?.state).toHaveProperty( + `${State.APP_PREFIX}config`, + 'light-mode', + ); + }); + + it('merges initial app: state with app state set by an event', async () => { + const appName = 'app'; + const session1 = await service.createSession({ + appName, + userId: 'user1', + state: {[`${State.APP_PREFIX}k1`]: 'v1'}, + }); + await service.appendEvent({ + session: session1, + event: createEvent({ + timestamp: Date.now(), + actions: createEventActions({ + stateDelta: {[`${State.APP_PREFIX}k2`]: 'v2'}, + }), + }), + }); + + const session2 = await service.createSession({appName, userId: 'user2'}); + + expect(session2.state).toEqual({ + [`${State.APP_PREFIX}k1`]: 'v1', + [`${State.APP_PREFIX}k2`]: 'v2', + }); + }); }); describe('getSession', () => {