Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced: - The generated Remote face now wraps every business result in RemoteResult, folding carrier failures into an ok:false branch instead of rejecting. The controller reads that envelope at its three call sites and maps a carrier failure onto the same settled shape the controls already render; three specs cover the new branch. - Client packages split their tsconfig into host and client halves, and the host aggregate now compiles any test not named *.client.spec.*. Rename this package's specs to the client convention and drop the ../connection project reference, which pointed at a solution file that no longer carries the client sources. Keep master's mount loop with its rollback-on-failure in api-remotes and add messageFeedbackRemote to it.
This commit is contained in:
@@ -55,6 +55,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
@@ -69,6 +70,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* @module @deepseek-ai/dsh-client-ui-feedback/client/controller
|
||||
*/
|
||||
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
@@ -17,21 +18,26 @@ import type {
|
||||
MessageFeedbackRating,
|
||||
} from '@deepseek-ai/dsh-message-feedback/types'
|
||||
|
||||
/** The three Remote calls this controller needs, named without the transport. */
|
||||
/**
|
||||
* The three Remote calls this controller needs. The generated face wraps every
|
||||
* business result in {@link RemoteResult}: a carrier failure arrives as the
|
||||
* `ok: false` branch rather than a rejection, so this controller reads one
|
||||
* envelope and never wraps a call to recover a transport error.
|
||||
*/
|
||||
export interface MessageFeedbackRemote {
|
||||
list: (request: { sessionId: SessionId }) => Promise<MessageFeedbackListResult>
|
||||
list: (request: { sessionId: SessionId }) => Promise<RemoteResult<MessageFeedbackListResult>>
|
||||
put: (request: {
|
||||
sessionId: SessionId
|
||||
messageId: MessageId
|
||||
rating: MessageFeedbackRating
|
||||
note?: string
|
||||
ifVersion: MessageFeedbackItem['version'] | null
|
||||
}) => Promise<MessageFeedbackPutResult>
|
||||
}) => Promise<RemoteResult<MessageFeedbackPutResult>>
|
||||
delete: (request: {
|
||||
sessionId: SessionId
|
||||
messageId: MessageId
|
||||
ifVersion: MessageFeedbackItem['version']
|
||||
}) => Promise<MessageFeedbackDeleteResult>
|
||||
}) => Promise<RemoteResult<MessageFeedbackDeleteResult>>
|
||||
}
|
||||
|
||||
/** Load state of the one list read that seeds every per-message control. */
|
||||
@@ -87,6 +93,11 @@ function fail(code: string): FeedbackActionResult {
|
||||
return { ok: false, error: { code, message: describe(code) } }
|
||||
}
|
||||
|
||||
/** Carrier failure rendered with the Host-supplied code and message. */
|
||||
function carrierFailure(error: { code: string; message: string }): FeedbackActionResult {
|
||||
return { ok: false, error: { code: error.code, message: error.message } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session feedback object layer. One instance backs every per-message
|
||||
* control in that Session, so a single list read seeds them all.
|
||||
@@ -231,13 +242,15 @@ export class FeedbackController implements HostObservable<FeedbackView> {
|
||||
note: string | undefined,
|
||||
observed: MessageFeedbackItem | undefined,
|
||||
): Promise<FeedbackActionResult> {
|
||||
const result = await this.remote.put({
|
||||
const carried = await this.remote.put({
|
||||
sessionId: this.sessionId,
|
||||
messageId,
|
||||
rating,
|
||||
...(note === undefined ? {} : { note }),
|
||||
ifVersion: observed?.version ?? null,
|
||||
})
|
||||
if (!carried.ok) return carrierFailure(carried.error)
|
||||
const result = carried.value
|
||||
if (result.ok) {
|
||||
this.commit(messageId, result.value)
|
||||
return OK
|
||||
@@ -251,11 +264,13 @@ export class FeedbackController implements HostObservable<FeedbackView> {
|
||||
messageId: MessageId,
|
||||
observed: MessageFeedbackItem,
|
||||
): Promise<FeedbackActionResult> {
|
||||
const result = await this.remote.delete({
|
||||
const carried = await this.remote.delete({
|
||||
sessionId: this.sessionId,
|
||||
messageId,
|
||||
ifVersion: observed.version,
|
||||
})
|
||||
if (!carried.ok) return carrierFailure(carried.error)
|
||||
const result = carried.value
|
||||
if (result.ok) {
|
||||
this.commit(messageId, null)
|
||||
return OK
|
||||
@@ -273,8 +288,13 @@ export class FeedbackController implements HostObservable<FeedbackView> {
|
||||
/** Fetch the whole sidecar and publish it as the seeded view. */
|
||||
private async load(): Promise<FeedbackActionResult> {
|
||||
try {
|
||||
const result = await this.remote.list({ sessionId: this.sessionId })
|
||||
const carried = await this.remote.list({ sessionId: this.sessionId })
|
||||
if (this.disposed) return OK
|
||||
if (!carried.ok) {
|
||||
this.publish({ status: 'error', items: this.view.items, error: carried.error.message })
|
||||
return carrierFailure(carried.error)
|
||||
}
|
||||
const result = carried.value
|
||||
if (!result.ok) {
|
||||
this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) })
|
||||
return fail(result.error.code)
|
||||
|
||||
@@ -36,18 +36,20 @@ const seeded: MessageFeedbackItem = {
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
// The generated face wraps every business result in the carrier envelope.
|
||||
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
|
||||
const messageFeedback = {
|
||||
list: (request: unknown) => {
|
||||
calls.push({ method: 'list', request })
|
||||
return Promise.resolve({ ok: true as const, value: { items: [seeded] } })
|
||||
return carried({ ok: true as const, value: { items: [seeded] } })
|
||||
},
|
||||
put: (request: unknown) => {
|
||||
calls.push({ method: 'put', request })
|
||||
return Promise.resolve({ ok: true as const, value: seeded })
|
||||
return carried({ ok: true as const, value: seeded })
|
||||
},
|
||||
delete: (request: unknown) => {
|
||||
calls.push({ method: 'delete', request })
|
||||
return Promise.resolve({ ok: true as const, value: { absent: true as const } })
|
||||
return carried({ ok: true as const, value: { absent: true as const } })
|
||||
},
|
||||
}
|
||||
class RemoteService extends Service {
|
||||
@@ -30,24 +30,34 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
|
||||
}
|
||||
|
||||
/** A recording fake Remote whose per-method answers are scripted per call. */
|
||||
function fakeRemote(script: Partial<MessageFeedbackRemote> = {}) {
|
||||
type Script = {
|
||||
list?: (request: unknown) => Promise<unknown>
|
||||
put?: (request: unknown) => Promise<unknown>
|
||||
delete?: (request: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* A recording fake Remote. Scripts return the *business* result; this wraps it
|
||||
* in the carrier envelope the generated face uses, so specs stay readable. A
|
||||
* script may also return an already-enveloped `{ok:false,error:{code,message,
|
||||
* details}}` to exercise a carrier failure.
|
||||
*/
|
||||
function fakeRemote(script: Script = {}) {
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
const record = <K extends keyof MessageFeedbackRemote>(
|
||||
method: K,
|
||||
real: MessageFeedbackRemote[K] | undefined,
|
||||
fallback: Awaited<ReturnType<MessageFeedbackRemote[K]>>,
|
||||
): MessageFeedbackRemote[K] =>
|
||||
((request: Parameters<MessageFeedbackRemote[K]>[0]) => {
|
||||
const isCarrier = (v: unknown): boolean =>
|
||||
typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
|
||||
&& 'error' in v && 'details' in ((v as { error: object }).error ?? {})
|
||||
const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
|
||||
(request: never): Promise<never> => {
|
||||
calls.push({ method, request })
|
||||
return real === undefined
|
||||
? Promise.resolve(fallback)
|
||||
: (real as (input: typeof request) => ReturnType<MessageFeedbackRemote[K]>)(request)
|
||||
}) as MessageFeedbackRemote[K]
|
||||
const remote: MessageFeedbackRemote = {
|
||||
const business = real === undefined ? Promise.resolve(fallback) : real(request)
|
||||
return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
|
||||
}
|
||||
const remote = {
|
||||
list: record('list', script.list, { ok: true, value: { items: [] } }),
|
||||
put: record('put', script.put, { ok: true, value: item() }),
|
||||
delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
|
||||
}
|
||||
} as unknown as MessageFeedbackRemote
|
||||
return { remote, calls }
|
||||
}
|
||||
|
||||
@@ -323,7 +333,7 @@ describe('FeedbackController', () => {
|
||||
it('swallows a rejected list that settles after disposal', async () => {
|
||||
let reject = (): void => {}
|
||||
const gate = new Promise<void>((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } })
|
||||
const { remote } = fakeRemote({ list: () => gate as Promise<never> })
|
||||
const { remote } = fakeRemote({ list: () => gate })
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
const pending = controller.ensure()
|
||||
|
||||
@@ -612,4 +622,53 @@ describe('FeedbackController', () => {
|
||||
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders a carrier failure from the Remote envelope', async () => {
|
||||
// The generated face folds transport faults into ok:false with a
|
||||
// RemoteFailure, so the controller reads them as values, not rejections.
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('renders a carrier failure on a mutation without touching the view', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
put: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a carrier failure on a delete', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
delete: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
|
||||
Reference in New Issue
Block a user