From 8818efc49e3dba86210fe0f5becae5eef3edcb5d Mon Sep 17 00:00:00 2001 From: MehakBindra Date: Thu, 13 Aug 2026 15:16:35 -0700 Subject: [PATCH] Add per-turn state support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e719380b-bd6a-4ae8-9650-6ffcee53fb7d --- packages/apps/src/app.process.spec.ts | 105 ++++++++++++++ packages/apps/src/app.process.ts | 21 +++ packages/apps/src/app.ts | 13 ++ packages/apps/src/contexts/activity.ts | 11 ++ packages/apps/src/index.ts | 1 + packages/apps/src/state/container.ts | 63 +++++++++ packages/apps/src/state/index.ts | 4 + packages/apps/src/state/loader.ts | 187 +++++++++++++++++++++++++ packages/apps/src/state/options.ts | 20 +++ packages/apps/src/state/state.spec.ts | 174 +++++++++++++++++++++++ packages/apps/src/state/turn-state.ts | 167 ++++++++++++++++++++++ 11 files changed, 766 insertions(+) create mode 100644 packages/apps/src/state/container.ts create mode 100644 packages/apps/src/state/index.ts create mode 100644 packages/apps/src/state/loader.ts create mode 100644 packages/apps/src/state/options.ts create mode 100644 packages/apps/src/state/state.spec.ts create mode 100644 packages/apps/src/state/turn-state.ts diff --git a/packages/apps/src/app.process.spec.ts b/packages/apps/src/app.process.spec.ts index 97d2963a7..037c962f7 100644 --- a/packages/apps/src/app.process.spec.ts +++ b/packages/apps/src/app.process.spec.ts @@ -2,6 +2,7 @@ import { context, propagation, ROOT_CONTEXT } from '@opentelemetry/api'; import type { Baggage, Context, ContextManager, Span, Tracer } from '@opentelemetry/api'; import { IMessageActivity, InvokeResponse, ISignInFailureInvokeActivity, ITaskFetchInvokeActivity, IToken, MessageActivity, TaskModuleResponse } from '@microsoft/teams.api'; +import { IStorage } from '@microsoft/teams.common'; import { ActivitySender } from './activity-sender'; import { App } from './app'; @@ -18,6 +19,7 @@ import { } from './diagnostics/helpers'; import { IActivityResponseEvent, IActivitySentEvent, IErrorEvent } from './events'; import { IActivityEvent } from './events/activity'; +import { TurnStateContainer } from './state'; import { createTestApp } from './test-utils'; jest.mock('./diagnostics/helpers', () => ({ @@ -135,6 +137,109 @@ describe('App', () => { }); describe('process', () => { + it('loads, exposes, persists, and seals per-turn state', async () => { + const data = new Map(); + const storage: IStorage = { + get: (key) => data.get(key), + set: (key, value) => { + data.set(key, value); + }, + delete: (key) => { + data.delete(key); + }, + }; + await app.stop(); + app = createTestApp({ state: { storage } }); + await app.start(); + const turnStates: TurnStateContainer[] = []; + const counts: number[] = []; + app.on('message', ({ state }) => { + if (!state) { + throw new Error('Expected state to be enabled.'); + } + turnStates.push(state); + const count = (state.conversation.get('count') ?? 0) + 1; + counts.push(count); + state.conversation.set('count', count); + }); + const stateActivity = new MessageActivity('hello') + .withFrom({ id: 'user-1', name: 'Test User', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-1', conversationType: 'personal' }) + .withChannelId('msteams') + .toInterface(); + + await app.process({ token, body: stateActivity }); + await app.process({ token, body: stateActivity }); + + expect(turnStates).toHaveLength(2); + expect(counts).toEqual([1, 2]); + expect(turnStates[0].conversation.isSealed).toBe(true); + expect(() => turnStates[0].conversation.get('count')).toThrow(); + }); + + it('persists dirty state when a handler fails', async () => { + const data = new Map(); + const storage: IStorage = { + get: (key) => data.get(key), + set: (key, value) => { + data.set(key, value); + }, + delete: (key) => { + data.delete(key); + }, + }; + await app.stop(); + app = createTestApp({ state: { storage } }); + await app.start(); + app.on('message', ({ state }) => { + state?.conversation.set('saved', true); + throw new Error('handler failed'); + }); + const stateActivity = new MessageActivity('hello') + .withFrom({ id: 'user-1', name: 'Test User', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-1', conversationType: 'personal' }) + .withChannelId('msteams') + .toInterface(); + + const response = await app.process({ token, body: stateActivity }); + + expect(response.status).toBe(500); + expect(JSON.parse(data.get('ts:conv:conv-1') ?? '{}').data).toEqual({ + saved: true, + }); + }); + + it('seals state and propagates the error when persistence fails', async () => { + const saveError = new Error('save failed'); + const storage: IStorage = { + get: () => undefined, + set: () => { + throw saveError; + }, + delete: () => undefined, + }; + await app.stop(); + app = createTestApp({ state: { storage } }); + await app.start(); + let capturedState: TurnStateContainer | undefined; + app.on('message', ({ state }) => { + capturedState = state; + state?.conversation.set('saved', true); + }); + const stateActivity = new MessageActivity('hello') + .withFrom({ id: 'user-1', name: 'Test User', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-1', conversationType: 'personal' }) + .withChannelId('msteams') + .toInterface(); + + await expect(app.process({ token, body: stateActivity })).rejects.toBe(saveError); + expect(capturedState?.conversation.isSealed).toBe(true); + expect(() => capturedState?.conversation.get('saved')).toThrow(); + }); + it('should return status 200 if no route matches', async () => { const event: IActivityEvent = { token: token, diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 5ef27aa64..7be968b9a 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -39,6 +39,7 @@ import { IActivityEvent } from './events'; import { Router } from './router'; import type { Route } from './router/route'; import { IRoutes } from './routes'; +import { TurnStateLoader } from './state'; import { IActivitySender, IPlugin, RouteHandler, StreamCancelledError } from './types'; import { PluginAdditionalContext } from './types/app-routing'; @@ -75,6 +76,10 @@ export interface IActivityProcessorOptions { readonly api: ApiClient; readonly client: HttpClient; readonly storage: IStorage; + /** + * Loader used to attach and persist state for each activity turn. + */ + readonly stateLoader?: TurnStateLoader; readonly log: ILogger; readonly getId: () => string | undefined; readonly getConnectionName: () => string; @@ -253,6 +258,14 @@ export class ActivityProcessor { ...pluginContexts }); + const conversationId = activity.conversation?.id; + if (this.options.stateLoader && conversationId) { + context.state = await this.options.stateLoader.load( + conversationId, + activity.from?.id + ); + } + const send = context.send.bind(context); context.send = async (activity: ActivityLike | DeprecatedInputActivity, conversationRef?: ConversationReference) => { const res = await send(activity, conversationRef ?? ref); @@ -312,6 +325,14 @@ export class ActivityProcessor { activity, response: response, }); + } finally { + if (context.state && this.options.stateLoader) { + try { + await this.options.stateLoader.save(context.state); + } finally { + context.state.seal(); + } + } } return response; diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 31b7a38f9..d7d60cce0 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -50,6 +50,7 @@ import { DEFAULT_OAUTH_SETTINGS, OAuthSettings } from './oauth'; import { HttpPlugin } from './plugins'; import { Router } from './router'; import { IRoutes } from './routes'; +import { createStateLoader, StateOptions, TurnStateLoader } from './state'; import { DEFAULT_TENANT_FOR_GRAPH_TOKEN, TokenManager } from './token-manager'; import { AppTokenProvider, IAppTokenProvider } from './token-provider'; import { AppEvents, IPlugin, PluginName, RouteHandler } from './types'; @@ -170,6 +171,15 @@ export type AppOptions = { */ readonly storage?: IStorage; + /** + * Enables per-turn conversation and user state. + * + * Pass `true` to use the app storage with default keys, or provide options + * to configure dedicated storage, key prefix, or expiration. State is + * disabled when omitted or `false`. + */ + readonly state?: boolean | StateOptions; + /** * plugins to extend the apps functionality */ @@ -329,6 +339,7 @@ export class App { private readonly tokenManager: TokenManager; private readonly _tokenProvider: AppTokenProvider; + private readonly stateLoader?: TurnStateLoader; private eventManager!: EventManager; private activityProcessor!: ActivityProcessor; @@ -339,6 +350,7 @@ export class App { constructor(readonly options: AppOptions = {}) { this.log = this.options.logger || new ConsoleLogger('@teams/app'); this.storage = this.options.storage || new LocalStorage(); + this.stateLoader = createStateLoader(this.options.state, this.storage, this.log); // Resolve cloud environment from options or CLOUD env var const cloudEnvName = typeof process !== 'undefined' ? process.env.CLOUD : undefined; @@ -446,6 +458,7 @@ export class App { api: this.api, client: this.client, storage: this.storage, + stateLoader: this.stateLoader, log: this.log, getId: () => this.id, getConnectionName: () => this.oauth.defaultConnectionName, diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index ff881940e..b1111f45e 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -21,6 +21,7 @@ import { import { ILogger, IStorage } from '@microsoft/teams.common'; import { ApiClient, GraphClient } from '../api'; +import { TurnStateContainer } from '../state'; import { IStreamer } from '../types'; import { IActivitySender } from '../types/plugin/sender'; @@ -87,6 +88,14 @@ export interface IBaseActivityContextOptions { */ storage: IStorage; + /** + * Conversation and user state loaded for this activity turn. + * + * This is undefined when state is disabled or the activity has no conversation ID. + * Do not retain the container after the handler completes; its scopes are sealed. + */ + state?: TurnStateContainer; + /** * whether the user has provided * their MSGraph credentials for use @@ -220,6 +229,7 @@ export class ActivityContext Promise; + +/** Conversation and user state loaded for one activity turn. */ +export class TurnStateContainer { + /** State shared by all users in the current conversation. */ + readonly conversation: TurnState; + + /** + * State for the current user within the current conversation. + * Undefined when the activity has no sender ID. + */ + readonly user?: TurnState; + + /** Conversation ID used to load and persist this container. */ + readonly conversationId: string; + + /** User ID used to load and persist the user scope. */ + readonly userId?: string; + + private readonly deleter: StateDeleter; + + /** + * Creates a loaded state container. + * @param conversation Conversation-scoped state. + * @param conversationId Conversation ID associated with the state. + * @param deleter Callback that removes persisted scopes. + * @param user Optional user-within-conversation state. + * @param userId Optional sender ID associated with the user scope. + */ + constructor( + conversation: TurnState, + conversationId: string, + deleter: StateDeleter, + user?: TurnState, + userId?: string + ) { + this.conversation = conversation; + this.conversationId = conversationId; + this.deleter = deleter; + this.user = user; + this.userId = userId; + } + + /** + * Deletes both persisted scopes and clears their in-memory snapshots. + * + * The backing delete must succeed before the in-memory state changes. Values + * written after this call are persisted normally at the end of the turn. + */ + async delete(): Promise { + await this.deleter(this.conversationId, this.userId); + this.conversation.reset(); + this.user?.reset(); + } + + /** Seals both scopes after activity processing completes. */ + seal(): void { + this.conversation.seal(); + this.user?.seal(); + } +} diff --git a/packages/apps/src/state/index.ts b/packages/apps/src/state/index.ts new file mode 100644 index 000000000..21f810fb0 --- /dev/null +++ b/packages/apps/src/state/index.ts @@ -0,0 +1,4 @@ +export { TurnState, TurnStateSealedError } from './turn-state'; +export { TurnStateContainer } from './container'; +export { TurnStateLoader, createStateLoader } from './loader'; +export type { StateOptions } from './options'; diff --git a/packages/apps/src/state/loader.ts b/packages/apps/src/state/loader.ts new file mode 100644 index 000000000..c7f46b560 --- /dev/null +++ b/packages/apps/src/state/loader.ts @@ -0,0 +1,187 @@ +import { ILogger, IStorage, LocalStorage } from '@microsoft/teams.common'; + +import { TurnStateContainer } from './container'; +import { StateOptions } from './options'; +import { TurnState } from './turn-state'; + +type StateEnvelope = { + readonly ts: number; + readonly data: Record; +}; + +/** + * Loads and saves per-turn state scopes using app storage. + * + * Each save replaces one complete scope. Concurrent turns therefore use + * last-writer-wins semantics. + */ +export class TurnStateLoader { + private readonly storage: IStorage; + private readonly keyPrefix: string; + private readonly ttl?: number; + + /** + * Creates a state loader. + * @param storage Storage for JSON state envelopes. + * @param options State key and expiration options. + */ + constructor(storage: IStorage, options: StateOptions = {}) { + this.storage = storage; + this.keyPrefix = options.keyPrefix ?? 'ts'; + this.ttl = options.ttl; + } + + /** + * Returns the persisted conversation-scope key. + * @param conversationId Conversation ID. + */ + conversationKey(conversationId: string): string { + return `${this.keyPrefix}:conv:${conversationId}`; + } + + /** + * Returns the persisted user-within-conversation key. + * @param conversationId Conversation ID. + * @param userId User ID. + */ + userKey(conversationId: string, userId: string): string { + return `${this.keyPrefix}:user:${conversationId}:${userId}`; + } + + /** + * Loads conversation state and, when a user ID is present, user state. + * @param conversationId Conversation ID for the activity. + * @param userId Optional sender ID. + */ + async load(conversationId: string, userId?: string): Promise { + if (!conversationId) { + throw new Error('A conversation ID is required to load turn state.'); + } + + const conversation = await this.loadScope(this.conversationKey(conversationId)); + const user = userId ? await this.loadScope(this.userKey(conversationId, userId)) : undefined; + + return new TurnStateContainer( + conversation, + conversationId, + (loadedConversationId, loadedUserId) => this.delete(loadedConversationId, loadedUserId), + user, + userId + ); + } + + /** + * Persists dirty scopes from a loaded container. + * + * Dirty empty scopes delete their backing keys; clean scopes perform no I/O. + * @param container State container to persist. + */ + async save(container: TurnStateContainer): Promise { + if (!container.conversationId) { + throw new Error('A conversation ID is required to save turn state.'); + } + + await this.saveScope( + this.conversationKey(container.conversationId), + container.conversation + ); + + if (container.user) { + if (!container.userId) { + throw new Error('A user ID is required to save user turn state.'); + } + await this.saveScope( + this.userKey(container.conversationId, container.userId), + container.user + ); + } + } + + /** + * Deletes persisted conversation and user scopes. + * @param conversationId Conversation ID to delete. + * @param userId Optional user ID to delete within the conversation. + */ + async delete(conversationId: string, userId?: string): Promise { + await this.storage.delete(this.conversationKey(conversationId)); + if (userId) { + await this.storage.delete(this.userKey(conversationId, userId)); + } + } + + private async loadScope(key: string): Promise { + const value = await this.storage.get(key); + if (!value) { + return new TurnState(); + } + + try { + const envelope: unknown = JSON.parse(value); + if (!isStateEnvelope(envelope)) { + return new TurnState(); + } + if (this.ttl !== undefined && Date.now() / 1000 - envelope.ts > this.ttl) { + return new TurnState(); + } + return new TurnState(envelope.data); + } catch { + return new TurnState(); + } + } + + private async saveScope(key: string, state: TurnState): Promise { + if (!state.isDirty) { + return; + } + if (state.isEmpty) { + await this.storage.delete(key); + return; + } + + const envelope: StateEnvelope = { + ts: Date.now() / 1000, + data: state.toRecord(), + }; + await this.storage.set(key, JSON.stringify(envelope)); + } +} + +/** + * Creates the app state loader when state is enabled. + * @param state `true` for defaults, options for custom behavior, or a falsy value to disable state. + * @param fallbackStorage App storage used when no dedicated state storage is set. + * @param logger Logger used to warn about process-local storage. + */ +export function createStateLoader( + state: boolean | StateOptions | undefined, + fallbackStorage: IStorage, + logger: ILogger +): TurnStateLoader | undefined { + if (!state) { + return undefined; + } + + const options = state === true ? {} : state; + const storage = options.storage ?? fallbackStorage as IStorage; + if (storage instanceof LocalStorage) { + logger.warn( + 'Per-turn state is using LocalStorage and will not be shared across processes. Configure state.storage for production.' + ); + } + + return new TurnStateLoader(storage, options); +} + +function isStateEnvelope(value: unknown): value is StateEnvelope { + if (!value || typeof value !== 'object') { + return false; + } + + const envelope = value as Record; + return ( + typeof envelope.ts === 'number' && + !!envelope.data && + typeof envelope.data === 'object' && + !Array.isArray(envelope.data) + ); +} diff --git a/packages/apps/src/state/options.ts b/packages/apps/src/state/options.ts new file mode 100644 index 000000000..773070fe9 --- /dev/null +++ b/packages/apps/src/state/options.ts @@ -0,0 +1,20 @@ +import type { IStorage } from '@microsoft/teams.common'; + +/** Configures per-turn conversation and user state. */ +export type StateOptions = { + /** Storage used for persisted state JSON. Defaults to the app's storage. */ + readonly storage?: IStorage; + + /** + * Prefix applied to conversation and user storage keys. + * @default 'ts' + */ + readonly keyPrefix?: string; + + /** + * Maximum age of persisted state in seconds. Omit to disable expiration. + * + * Expired values are treated as absent and are not deleted automatically. + */ + readonly ttl?: number; +}; diff --git a/packages/apps/src/state/state.spec.ts b/packages/apps/src/state/state.spec.ts new file mode 100644 index 000000000..d031ad6f5 --- /dev/null +++ b/packages/apps/src/state/state.spec.ts @@ -0,0 +1,174 @@ +import { ILogger, IStorage, LocalStorage } from '@microsoft/teams.common'; + +import { TurnStateContainer } from './container'; +import { createStateLoader, TurnStateLoader } from './loader'; +import { TurnState, TurnStateSealedError } from './turn-state'; + +class TestStorage implements IStorage { + readonly data = new Map(); + readonly get = jest.fn((key: string) => this.data.get(key)); + readonly set = jest.fn((key: string, value: string) => { + this.data.set(key, value); + }); + readonly delete = jest.fn((key: string) => { + this.data.delete(key); + }); +} + +describe('TurnState', () => { + it('tracks only effective mutations as dirty', () => { + const state = new TurnState({ existing: 1 }); + + expect(state.isDirty).toBe(false); + expect(state.delete('missing')).toBe(false); + expect(state.isDirty).toBe(false); + + state.set('existing', 1); + expect(state.isDirty).toBe(true); + }); + + it('uses snapshot iterators so mutation during iteration is safe', () => { + const state = new TurnState({ first: 1, second: 2 }); + const visited: string[] = []; + + for (const [key] of state) { + visited.push(key); + state.delete(key); + } + + expect(visited).toEqual(['first', 'second']); + expect(state.isEmpty).toBe(true); + }); + + it('blocks access after sealing while preserving metadata and persistence snapshots', () => { + const state = new TurnState({ value: 1 }); + state.seal(); + + expect(state.isSealed).toBe(true); + expect(state.size).toBe(1); + expect(state.toRecord()).toEqual({ value: 1 }); + expect(() => state.get('value')).toThrow(TurnStateSealedError); + expect(() => state.set('value', 2)).toThrow(TurnStateSealedError); + expect(() => state.has('value')).toThrow(TurnStateSealedError); + }); +}); + +describe('TurnStateLoader', () => { + it('uses the cross-SDK key format and round-trips both scopes', async () => { + const storage = new TestStorage(); + const loader = new TurnStateLoader(storage); + const state = await loader.load('conversation-1', 'user-1'); + + state.conversation.set('shared', 1); + state.user?.set('personal', 2); + await loader.save(state); + + expect(storage.set).toHaveBeenCalledWith( + 'ts:conv:conversation-1', + expect.any(String) + ); + expect(storage.set).toHaveBeenCalledWith( + 'ts:user:conversation-1:user-1', + expect.any(String) + ); + + const loaded = await loader.load('conversation-1', 'user-1'); + expect(loaded.conversation.get('shared')).toBe(1); + expect(loaded.user?.get('personal')).toBe(2); + expect(loaded.conversation.isDirty).toBe(false); + expect(loaded.user?.isDirty).toBe(false); + }); + + it('performs no writes for clean scopes and deletes dirty empty scopes', async () => { + const storage = new TestStorage(); + const loader = new TurnStateLoader(storage); + const state = await loader.load('conversation-1'); + + await loader.save(state); + expect(storage.set).not.toHaveBeenCalled(); + expect(storage.delete).not.toHaveBeenCalled(); + + state.conversation.set('value', 1); + state.conversation.clear(); + await loader.save(state); + expect(storage.delete).toHaveBeenCalledWith('ts:conv:conversation-1'); + }); + + it('treats malformed and expired values as absent', async () => { + const storage = new TestStorage(); + storage.data.set('ts:conv:malformed', '{'); + storage.data.set( + 'ts:conv:expired', + JSON.stringify({ ts: Date.now() / 1000 - 30, data: { value: 1 } }) + ); + const loader = new TurnStateLoader(storage, { ttl: 10 }); + + expect((await loader.load('malformed')).conversation.isEmpty).toBe(true); + expect((await loader.load('expired')).conversation.isEmpty).toBe(true); + }); + + it('omits user state when no user ID is available', async () => { + const storage = new TestStorage(); + const state = await new TurnStateLoader(storage).load('conversation-1'); + + expect(state.user).toBeUndefined(); + expect(storage.get).toHaveBeenCalledTimes(1); + }); + + it('deletes backing state before clearing memory and allows later persistence', async () => { + const storage = new TestStorage(); + const loader = new TurnStateLoader(storage); + const state = new TurnStateContainer( + new TurnState({ shared: 1 }), + 'conversation-1', + (conversationId, userId) => loader.delete(conversationId, userId), + new TurnState({ personal: 2 }), + 'user-1' + ); + + await state.delete(); + expect(state.conversation.isEmpty).toBe(true); + expect(state.conversation.isDirty).toBe(false); + expect(state.user?.isDirty).toBe(false); + + state.conversation.set('new', 3); + await loader.save(state); + expect(storage.set).toHaveBeenCalledWith( + 'ts:conv:conversation-1', + expect.any(String) + ); + }); +}); + +describe('createStateLoader', () => { + const logger = { + warn: jest.fn(), + } as unknown as ILogger; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables state for omitted and false options', () => { + const storage = new TestStorage(); + expect(createStateLoader(undefined, storage, logger)).toBeUndefined(); + expect(createStateLoader(false, storage, logger)).toBeUndefined(); + }); + + it('warns when state resolves to process-local storage', () => { + createStateLoader(true, new LocalStorage(), logger); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('uses dedicated state storage without warning', () => { + const stateStorage = new TestStorage(); + const loader = createStateLoader( + { storage: stateStorage, keyPrefix: 'custom' }, + new LocalStorage(), + logger + ); + + expect(loader?.conversationKey('id')).toBe('custom:conv:id'); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apps/src/state/turn-state.ts b/packages/apps/src/state/turn-state.ts new file mode 100644 index 000000000..23fc99b88 --- /dev/null +++ b/packages/apps/src/state/turn-state.ts @@ -0,0 +1,167 @@ +/** + * Error thrown when code accesses turn state after its activity turn has ended. + */ +export class TurnStateSealedError extends Error { + constructor() { + super('Turn state is no longer available because the activity turn has completed.'); + this.name = 'TurnStateSealedError'; + } +} + +/** + * Mutable key/value state scoped to one activity turn. + * + * Values are persisted as JSON at the end of the turn when the state is dirty. + * Instances are not thread-safe and must not be retained after the handler completes. + */ +export class TurnState implements Iterable<[string, unknown]> { + private readonly data: Map; + private dirty = false; + private sealed = false; + + /** + * Creates turn state from an optional persisted snapshot. + * @param data Initial values. Loading values does not mark the state dirty. + */ + constructor(data: Readonly> = {}) { + this.data = new Map(Object.entries(data)); + } + + /** Whether this scope changed during the current turn. */ + get isDirty(): boolean { + return this.dirty; + } + + /** Whether this scope contains no values. */ + get isEmpty(): boolean { + return this.data.size === 0; + } + + /** Whether this scope has been sealed after turn processing. */ + get isSealed(): boolean { + return this.sealed; + } + + /** Number of values in this scope. */ + get size(): number { + return this.data.size; + } + + /** + * Reads a value from this scope. + * @param key Stable key used to persist the value. + */ + get(key: string): T | undefined { + this.ensureActive(); + return this.data.get(key) as T | undefined; + } + + /** + * Writes a value and marks this scope dirty. + * + * Values must be JSON-serializable when the turn is saved. + * @param key Stable key used to persist the value. + * @param value Value to store. + */ + set(key: string, value: T): this { + this.ensureActive(); + this.data.set(key, value); + this.dirty = true; + return this; + } + + /** + * Removes a value. The scope is marked dirty only when the key existed. + * @param key Key to remove. + */ + delete(key: string): boolean { + this.ensureActive(); + const deleted = this.data.delete(key); + this.dirty ||= deleted; + return deleted; + } + + /** + * Tests whether a key exists in this scope. + * @param key Key to test. + */ + has(key: string): boolean { + this.ensureActive(); + return this.data.has(key); + } + + /** Removes every value. An already-empty scope remains clean. */ + clear(): void { + this.ensureActive(); + if (this.data.size === 0) { + return; + } + this.data.clear(); + this.dirty = true; + } + + /** Returns a snapshot iterator over the keys in this scope. */ + keys(): IterableIterator { + this.ensureActive(); + return new Map(this.data).keys(); + } + + /** Returns a snapshot iterator over the values in this scope. */ + values(): IterableIterator { + this.ensureActive(); + return new Map(this.data).values(); + } + + /** Returns a snapshot iterator over the entries in this scope. */ + entries(): IterableIterator<[string, unknown]> { + this.ensureActive(); + return new Map(this.data).entries(); + } + + /** + * Invokes a callback for each value in a snapshot of this scope. + * @param callback Callback invoked with each value, key, and this state object. + * @param thisArg Optional callback receiver. + */ + forEach( + callback: (value: unknown, key: string, state: TurnState) => void, + thisArg?: unknown + ): void { + this.ensureActive(); + for (const [key, value] of new Map(this.data)) { + callback.call(thisArg, value, key, this); + } + } + + /** + * Returns a shallow record snapshot for persistence. + * + * This remains available after sealing so storage adapters can finish a save. + */ + toRecord(): Record { + return Object.fromEntries(this.data); + } + + /** Seals this scope so handlers cannot access it after the turn completes. */ + seal(): void { + this.sealed = true; + } + + /** Returns a snapshot iterator over this scope. */ + [Symbol.iterator](): IterableIterator<[string, unknown]> { + return this.entries(); + } + + /** @internal Clears state after persisted records are deleted. */ + reset(): void { + this.ensureActive(); + this.data.clear(); + this.dirty = false; + } + + private ensureActive(): void { + if (this.sealed) { + throw new TurnStateSealedError(); + } + } +}