import type { MessageId, SessionId } from '../../types/ids/index.js' import { type MessageFeedback, type MessageFeedbackStore, type PutMessageFeedbackInput, StaleFeedbackError, UnknownMessageError, } from './types.js' /** * Feedback held in one process, for tests and for a host that does not want * a file. * * Shares its compare-and-set and its validation with the disk store by * running the same conformance suite rather than by sharing code — the two * built-in checkpoint stores diverged at exactly their enforcement point * once already, and the one documented as the reference carried the defect. */ /** * `hasMessage`: whether a session's log holds a message. Injected, never * inferred — answered by the session index or by the log's fold. */ export type MessageExistenceCheck = (sessionId: SessionId, messageId: MessageId) => Promise /** * Accepts every message id. * * The default for a store with no session log to consult, and it is named so a * reader sees the hole rather than discovering it. A host wiring this to a * real runtime passes the disk check; a test that is not about validation * takes this and says so by taking it. */ export const acceptAnyMessage: MessageExistenceCheck = async () => true function key(sessionId: SessionId, messageId: MessageId): string { return `${sessionId}${messageId}` } export class InMemoryMessageFeedbackStore implements MessageFeedbackStore { private readonly records = new Map() constructor( private readonly messageExists: MessageExistenceCheck = acceptAnyMessage, private readonly now: () => number = Date.now, ) {} async putMessageFeedback(input: PutMessageFeedbackInput): Promise { // Validated BEFORE the version check, so a rating aimed at a message // that does not exist is refused for what it is rather than reported // as a version conflict — which would send the caller off to re-read // a record that was never going to be written. if (!(await this.messageExists(input.sessionId, input.messageId))) { throw new UnknownMessageError({ sessionId: input.sessionId, messageId: input.messageId }) } const k = key(input.sessionId, input.messageId) const existing = this.records.get(k) const actualVersion = existing?.ownerVersion ?? 0 if (input.expectedVersion !== actualVersion) { throw new StaleFeedbackError({ sessionId: input.sessionId, messageId: input.messageId, expectedVersion: input.expectedVersion, actualVersion, }) } const timestamp = this.now() const record: MessageFeedback = { sessionId: input.sessionId, messageId: input.messageId, rating: input.rating, ...(input.note !== undefined ? { note: input.note } : {}), ownerVersion: actualVersion + 1, createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, } this.records.set(k, record) return record } async listMessageFeedback(query: { sessionId: SessionId }): Promise { // Sorted by message id, not by insertion. Insertion order is a // property of how a process happened to run; a listing a human or a // diff reads has to be the same on two machines. return [...this.records.values()] .filter((r) => r.sessionId === query.sessionId) .sort((a, b) => a.messageId.localeCompare(b.messageId)) } }