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:
Chinesezjc
2026-08-12 10:43:23 +08:00
parent 47f254a252
commit b462d5fd69
507 changed files with 3130 additions and 2238 deletions

View File

@@ -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 {

View File

@@ -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)
})
})