Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 34 additions & 18 deletions core/src/sessions/in_memory_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -52,18 +51,45 @@ export class InMemorySessionService extends BaseSessionService {
*/
private appState: Record<string, Record<string, unknown>> = {};

/**
* 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<Session> {
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(),
});
Expand Down Expand Up @@ -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];
Expand Down
135 changes: 135 additions & 0 deletions core/test/sessions/in_memory_session_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading