mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(mobile): add typed RPC operations and fence raw requests (#20018)
* feat(mobile): add the RpcOperation descriptor, send, and barrier interpretation An operation family declares its method, compatible reader, acceptance policy and interpretation barrier once. The send classifies only a fulfilled envelope; transport rejection stays on the promise channel as the original error object, so the cutover and delivery-unknown predicates keep working and a Promise.all group still fails fast. Multi-request families go through a post-barrier combinator that awaits every raw request and then interprets in declared order. No production call site is migrated: this lands as self-contained machinery so runtime behaviour is provably untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): require a reader for RPC result variants * refactor(mobile): fence the raw RPC request port behind an inventoried boundary The raw sender takes an unchecked method string and returns an envelope whose result is `unknown`; 153 non-test files still reach it and each re-decides acceptance and decoding for itself. The type system cannot close that today — `RpcClient` structurally carries `sendRequest` and ~190 files hold a client — so move the port's declaration into its own module, name it unvalidated, and hold the boundary as a ratcheted inventory instead. `SendRequestOptions` is re-exported from rpc-client.ts so the move touches no call site, and rpc-operation.ts now asks for the port rather than the whole client: it is the one module allowed to cross it. Two ratchets, both AST-based: - the port inventory fails on an unlisted file, a stale entry, and a listed file whose reference count went up, so the list only shrinks; - the cast fence bans `as`, `any` and `@ts-` suppressions in the operation region, which is computed from the imports rather than listed, so step 4's operation modules land inside it automatically. Zero runtime change: no wire change, no call site touched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * merge: incorporate closed boundary and send-side types * fix(mobile): preserve RPC decoding invariants across the combined boundary * fix(mobile): consolidate RPC operation test imports * refactor(mobile): simplify RPC descriptors and fence the contract module * fix(mobile): baseline landed notification RPC callers
This commit is contained in:
@@ -197,9 +197,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')}
|
||||
|
||||
export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
|
||||
|
||||
// Why: z.output is the post-parse shape the handler receives. z.input is not a
|
||||
// send-side type here — requiredString is z.unknown().transform(...), so its input
|
||||
// admits any value and loses optional/default semantics.
|
||||
// Why: z.output is the post-parse shape the handler receives, which is not what a
|
||||
// client may send — a .default() field reads as required. z.input is not the answer
|
||||
// either: requiredString is z.unknown().transform(...), so its input admits any value.
|
||||
// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map.
|
||||
export type RpcParams<Method extends RpcMethodName> =
|
||||
(typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
|
||||
? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { BrowserScreencastFrame } from './browser-screencast-protocol'
|
||||
import { DirectRpcClient } from './direct-rpc-client'
|
||||
import type {
|
||||
ConnectionLogSink,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
import type { ConnectionLogSink, ConnectionState, ForegroundNudgeReason } from './types'
|
||||
import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'
|
||||
|
||||
export type SendRequestOptions = {
|
||||
timeoutMs?: number
|
||||
/** Include the connect wait in the caller's timeout budget. */
|
||||
budgetSpansConnect?: boolean
|
||||
/** Reject instead of replaying the request after reconnect. */
|
||||
failWhenDisconnected?: boolean
|
||||
}
|
||||
// Re-export shim: the options type moved to the port module with the sender it belongs to,
|
||||
// and re-exporting is what keeps that move from touching every importer.
|
||||
export type { SendRequestOptions } from './unvalidated-rpc-request-port'
|
||||
|
||||
type SubscribeOptions = {
|
||||
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
|
||||
@@ -21,12 +13,9 @@ type SubscribeOptions = {
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
export type RpcClient = {
|
||||
sendRequest: (
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: SendRequestOptions
|
||||
) => Promise<RpcResponse>
|
||||
// Still structurally carries the raw sender, so holding a client is still holding the port —
|
||||
// which is why the boundary is inventoried rather than merely declared.
|
||||
export type RpcClient = UnvalidatedRpcRequestPort & {
|
||||
subscribe: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RpcDecodeIssue } from './rpc-operation-contract'
|
||||
|
||||
const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: '
|
||||
|
||||
// Why: a reply the operation's reader cannot read says nothing about what the host did.
|
||||
// On a mutation it is NOT evidence the mutation failed and authorizes no retry — only a
|
||||
// host-negotiated idempotency capability inside its dedupe window does (see
|
||||
// tasks/worktree-create-retry.ts). So this error is deliberately neither marked
|
||||
// delivery-unknown nor shaped like the cutover error the retry loops replay on.
|
||||
export class RpcIncompatibleReplyError extends Error {
|
||||
constructor(
|
||||
readonly operationName: string,
|
||||
readonly method: string,
|
||||
readonly issues: readonly RpcDecodeIssue[]
|
||||
) {
|
||||
super(`${INCOMPATIBLE_REPLY_MESSAGE_PREFIX}${operationName} (${method})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: instanceof can miss across bundle copies, so also match by message, mirroring
|
||||
// isLogicalClientCutoverError.
|
||||
export function isRpcIncompatibleReplyError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RpcIncompatibleReplyError ||
|
||||
(error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from './types'
|
||||
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
|
||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { interpretAtRpcBarrier, startRpcOperation } from './rpc-operation'
|
||||
import {
|
||||
rpcRefusal,
|
||||
rpcSuccess,
|
||||
terminalListAtBarrier,
|
||||
workspaceListAtBarrier,
|
||||
worktreePsProbeAtBarrier
|
||||
} from './rpc-operation-test-families'
|
||||
|
||||
const rows = { worktrees: [{ id: 'w1' }] }
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function replying(response: RpcResponse): FakeSession {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockResolvedValue(response)
|
||||
return session
|
||||
}
|
||||
|
||||
function settleAfter(milliseconds: number): Promise<'still waiting'> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve('still waiting'), milliseconds))
|
||||
}
|
||||
|
||||
describe('the post-barrier combinator', () => {
|
||||
it('starts every request before anything is awaited', () => {
|
||||
const first = replying(rpcSuccess(rows))
|
||||
const second = replying(rpcSuccess({ terminals: [] }))
|
||||
|
||||
startRpcOperation(first, workspaceListAtBarrier, {})
|
||||
startRpcOperation(second, terminalListAtBarrier, {})
|
||||
|
||||
expect(first.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(second.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('yields one verdict per operation, in declared order', async () => {
|
||||
const verdicts = await interpretAtRpcBarrier([
|
||||
startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}),
|
||||
startRpcOperation(replying(rpcSuccess({ terminals: [] })), terminalListAtBarrier, {}),
|
||||
startRpcOperation(replying(rpcSuccess(rows)), worktreePsProbeAtBarrier, {})
|
||||
])
|
||||
|
||||
expect(verdicts).toEqual([rows, { terminals: [] }, false])
|
||||
})
|
||||
|
||||
// The bug class this exists to remove: whichever peer lost the race used to decide which
|
||||
// error the user saw. Here the second request fails first in time and the first one refuses
|
||||
// afterwards, and the declaration still decides.
|
||||
it('interprets in declared order rather than completion order', async () => {
|
||||
const lateRefusal = deferred<RpcResponse>()
|
||||
const refusing = new FakeSession('connected')
|
||||
refusing.sendRequest.mockReturnValue(lateRefusal.promise)
|
||||
const dropped = new FakeSession('connected')
|
||||
dropped.sendRequest.mockRejectedValue(new Error('socket closed first'))
|
||||
|
||||
const barrier = interpretAtRpcBarrier([
|
||||
startRpcOperation(refusing, workspaceListAtBarrier, {}),
|
||||
startRpcOperation(dropped, terminalListAtBarrier, {})
|
||||
])
|
||||
lateRefusal.resolve(rpcRefusal('method_not_found', 'no such method'))
|
||||
|
||||
await expect(barrier).rejects.toThrow('method_not_found: no such method')
|
||||
})
|
||||
|
||||
it('keeps the middle operation error when a later one also fails', async () => {
|
||||
const barrier = interpretAtRpcBarrier([
|
||||
startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}),
|
||||
startRpcOperation(
|
||||
replying(rpcRefusal('conflict', 'middle refused')),
|
||||
workspaceListAtBarrier,
|
||||
{}
|
||||
),
|
||||
startRpcOperation(
|
||||
replying(rpcRefusal('runtime_error', 'later refused')),
|
||||
workspaceListAtBarrier,
|
||||
{}
|
||||
)
|
||||
])
|
||||
|
||||
await expect(barrier).rejects.toThrow('conflict: middle refused')
|
||||
})
|
||||
|
||||
it('does not interpret until every raw request has settled', async () => {
|
||||
const refusing = replying(rpcRefusal('runtime_error', 'boom'))
|
||||
const pending = deferred<RpcResponse>()
|
||||
const stalled = new FakeSession('connected')
|
||||
stalled.sendRequest.mockReturnValue(pending.promise)
|
||||
|
||||
const barrier = interpretAtRpcBarrier([
|
||||
startRpcOperation(refusing, workspaceListAtBarrier, {}),
|
||||
startRpcOperation(stalled, terminalListAtBarrier, {})
|
||||
])
|
||||
const raced = await Promise.race([
|
||||
barrier.then(
|
||||
() => 'resolved' as const,
|
||||
() => 'rejected' as const
|
||||
),
|
||||
settleAfter(50)
|
||||
])
|
||||
expect(raced).toBe('still waiting')
|
||||
|
||||
pending.resolve(rpcSuccess({ terminals: [] }))
|
||||
await expect(barrier).rejects.toThrow('runtime_error: boom')
|
||||
})
|
||||
|
||||
it('rethrows a captured transport rejection as the original error object', async () => {
|
||||
const error = markRpcDeliveryUnknown(new Error('socket closed before response'))
|
||||
const dropped = new FakeSession('connected')
|
||||
dropped.sendRequest.mockRejectedValue(error)
|
||||
|
||||
const caught = await interpretAtRpcBarrier([
|
||||
startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}),
|
||||
startRpcOperation(dropped, terminalListAtBarrier, {})
|
||||
]).catch((thrown: unknown) => thrown)
|
||||
|
||||
expect(caught).toBe(error)
|
||||
expect(isRpcDeliveryUnknown(caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('applies the policy each family declared, at the barrier', async () => {
|
||||
const verdicts = await interpretAtRpcBarrier([
|
||||
startRpcOperation(replying(rpcRefusal('runtime_error')), terminalListAtBarrier, {}),
|
||||
startRpcOperation(replying(rpcRefusal('method_not_found')), worktreePsProbeAtBarrier, {})
|
||||
])
|
||||
|
||||
expect(verdicts).toEqual([null, true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Bans the escapes that would make the typed boundary decorative.
|
||||
*
|
||||
* An operation's whole claim is that a reply arrives as a declared type because a reader
|
||||
* decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without
|
||||
* the decode, so one of them anywhere in an operation implementation buys back exactly the
|
||||
* drift the contract removed — and it buys it silently, since the code still compiles and the
|
||||
* types still read as validated.
|
||||
*
|
||||
* The fenced region includes the operation API, contract and result-reader factory, plus
|
||||
* non-test files importing them and files that re-export a
|
||||
* file that is (transitively). Step 4's operation modules therefore land inside the fence the
|
||||
* moment they are written, with nothing to remember.
|
||||
*
|
||||
* What this does NOT catch, all accepted:
|
||||
* - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no
|
||||
* syntax check can tell a permissive schema from a wrong one.
|
||||
* - Structural laundering: a helper in an unfenced module that returns the wrong type
|
||||
* honestly, which the operation then consumes without a cast.
|
||||
* - `!` non-null assertions, and the widening that an untyped intermediate variable gives
|
||||
* you for free.
|
||||
* - A screen. Screens are outside the region by design until they hold an operation; the
|
||||
* raw-port inventory is what governs them.
|
||||
*/
|
||||
|
||||
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
|
||||
const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
||||
const transportRoot = join(mobileRoot, 'src', 'transport')
|
||||
|
||||
/** Importing any of these is what makes a file an operation implementation. */
|
||||
const REGION_SEEDS = new Set(
|
||||
['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) =>
|
||||
join(transportRoot, name)
|
||||
)
|
||||
)
|
||||
|
||||
export type RpcOperationEscape = 'assertion' | 'any' | 'suppression'
|
||||
|
||||
type CastFenceException = {
|
||||
readonly file: string
|
||||
readonly allows: readonly RpcOperationEscape[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules that own the `unknown` → declared-type transition, so the erasure has to land
|
||||
* somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the
|
||||
* others. Every entry is also checked for staleness.
|
||||
*/
|
||||
const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [
|
||||
// The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the
|
||||
// way in; none of them invents a shape the reader did not already produce.
|
||||
{ file: 'src/transport/rpc-operation.ts', allows: ['assertion'] },
|
||||
// The reader factory. `safeParse` returns the schema's own output type as `unknown`.
|
||||
{ file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] },
|
||||
// Nothing but suppressions: every directive in it is an assertion that tsc still rejects
|
||||
// the thing above it, which is the compile fence's entire mechanism.
|
||||
{ file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] }
|
||||
]
|
||||
|
||||
// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled
|
||||
// inside a string literal therefore reads as one — which fails closed.
|
||||
const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
)
|
||||
}
|
||||
|
||||
function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null {
|
||||
if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) {
|
||||
return null
|
||||
}
|
||||
return resolve(path, '..', node.text)
|
||||
}
|
||||
|
||||
/** `as const` narrows a literal; it declares nothing the value was not already. */
|
||||
function isConstAssertion(node: ts.AsExpression): boolean {
|
||||
return (
|
||||
ts.isTypeReferenceNode(node.type) &&
|
||||
ts.isIdentifier(node.type.typeName) &&
|
||||
node.type.typeName.text === 'const'
|
||||
)
|
||||
}
|
||||
|
||||
export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] {
|
||||
const found: RpcOperationEscape[] = []
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
(ts.isAsExpression(node) && !isConstAssertion(node)) ||
|
||||
ts.isTypeAssertionExpression(node)
|
||||
) {
|
||||
found.push('assertion')
|
||||
}
|
||||
if (node.kind === ts.SyntaxKind.AnyKeyword) {
|
||||
found.push('any')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(parse(path, source))
|
||||
if (SUPPRESSION.test(source)) {
|
||||
found.push('suppression')
|
||||
}
|
||||
return [...new Set(found)].sort()
|
||||
}
|
||||
|
||||
/** Imports and re-exports that make the importer part of the operation region. */
|
||||
function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } {
|
||||
const imports: string[] = []
|
||||
const reExports: string[] = []
|
||||
for (const statement of parse(path, source).statements) {
|
||||
if (ts.isImportDeclaration(statement)) {
|
||||
const target = resolvedSpecifier(path, statement.moduleSpecifier)
|
||||
if (target) {
|
||||
imports.push(target)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
|
||||
const target = resolvedSpecifier(path, statement.moduleSpecifier)
|
||||
if (target) {
|
||||
imports.push(target)
|
||||
reExports.push(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { imports, reExports }
|
||||
}
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
|
||||
const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const))
|
||||
const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)]))
|
||||
|
||||
/** Modules are keyed without their extension, the way a relative specifier resolves. */
|
||||
function moduleKey(path: string): string {
|
||||
return path.replace(/\.[jt]sx?$/, '')
|
||||
}
|
||||
|
||||
const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path))))
|
||||
for (const [path, { imports }] of edges) {
|
||||
if (imports.some((target) => REGION_SEEDS.has(target))) {
|
||||
region.add(path)
|
||||
}
|
||||
}
|
||||
// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence
|
||||
// too, which is where a cast would otherwise sit unwatched between definition and screen.
|
||||
for (let changed = true; changed;) {
|
||||
changed = false
|
||||
const members = new Set([...region].map(moduleKey))
|
||||
for (const [path, { reExports }] of edges) {
|
||||
if (!region.has(path) && reExports.some((target) => members.has(target))) {
|
||||
region.add(path)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relativeRegion = [...region].map((path) =>
|
||||
relative(mobileRoot, path).split(/[/\\]/).join('/')
|
||||
)
|
||||
|
||||
describe('RPC operation cast fence', () => {
|
||||
const probe = join(mobileRoot, 'src', 'transport', 'probe.ts')
|
||||
|
||||
it('recognizes each escape and leaves honest code alone', () => {
|
||||
expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion'])
|
||||
expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([
|
||||
'assertion'
|
||||
])
|
||||
expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any'])
|
||||
expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any'])
|
||||
expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion'])
|
||||
expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([
|
||||
'suppression'
|
||||
])
|
||||
expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression'])
|
||||
expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([])
|
||||
expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([])
|
||||
expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([])
|
||||
expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([])
|
||||
})
|
||||
|
||||
it('puts every operation module in the fenced region', () => {
|
||||
for (const file of [
|
||||
'src/transport/rpc-operation.ts',
|
||||
'src/transport/rpc-operation-contract.ts',
|
||||
'src/transport/rpc-operation-test-families.ts',
|
||||
'src/transport/rpc-operation-compile-fence.ts',
|
||||
'src/transport/rpc-operation-result-reader.ts',
|
||||
'src/transport/rpc-incompatible-reply-error.ts'
|
||||
]) {
|
||||
expect(relativeRegion, `${file} must be fenced`).toContain(file)
|
||||
}
|
||||
// A screen that only holds a client is governed by the raw-port inventory, not by this.
|
||||
expect(relativeRegion).not.toContain('src/transport/rpc-client.ts')
|
||||
})
|
||||
|
||||
it('has no operation module casting, widening or suppressing its way to a type', () => {
|
||||
const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows]))
|
||||
const offenders = [...region]
|
||||
.map((path) => {
|
||||
const file = relative(mobileRoot, path).split(/[/\\]/).join('/')
|
||||
const escapes = rpcOperationEscapes(path, sources.get(path) ?? '')
|
||||
const permitted = allowed.get(file) ?? []
|
||||
return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) }
|
||||
})
|
||||
.filter((entry) => entry.escapes.length > 0)
|
||||
.map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`)
|
||||
.sort()
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('has no stale cast-fence exception', () => {
|
||||
const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => {
|
||||
const path = join(mobileRoot, entry.file)
|
||||
if (!region.has(path)) {
|
||||
return [`${entry.file}: no longer in the fenced region`]
|
||||
}
|
||||
const escapes = rpcOperationEscapes(path, sources.get(path) ?? '')
|
||||
return entry.allows
|
||||
.filter((escape) => !escapes.includes(escape))
|
||||
.map((escape) => `${entry.file}: no longer uses '${escape}'`)
|
||||
})
|
||||
expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcMethodName, RpcParams, RpcSendParams } from './rpc-params-contract'
|
||||
import { defineRpcOperation, runRpcOperation, startRpcOperation } from './rpc-operation'
|
||||
import { rpcResultVariants } from './rpc-operation-result-reader'
|
||||
import {
|
||||
workspaceListAtBarrier,
|
||||
workspaceListOrNull,
|
||||
workspaceRowsReader,
|
||||
worktreePsProbe,
|
||||
type WorkspaceRows
|
||||
} from './rpc-operation-test-families'
|
||||
import type {
|
||||
CapabilityProbeRpcDefinition,
|
||||
ObjectResultRpcDefinition,
|
||||
RequireResultRpcDefinition,
|
||||
RpcAcceptanceName,
|
||||
RpcCompatibleReader,
|
||||
RpcOperation
|
||||
} from './rpc-operation-contract'
|
||||
|
||||
// Why this file exists: the descriptor's whole point is that a call site cannot pick the
|
||||
// acceptance policy, the interpretation barrier, or the send-side params for itself. Every
|
||||
// expect-error directive below is that claim as an assertion — tsc fails on a directive that
|
||||
// stops catching an error, so `pnpm --dir mobile typecheck` is the gate. Nothing here runs and
|
||||
// no app code imports it.
|
||||
|
||||
declare const client: RpcClient
|
||||
|
||||
// @ts-expect-error a variant reader combinator must have at least one reader
|
||||
const _fenceEmptyVariantReaders = rpcResultVariants([])
|
||||
|
||||
export const fenceProbeWithReader: CapabilityProbeRpcDefinition<'worktree.ps', 'on-settle'> = {
|
||||
name: 'fence.probeWithReader',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle',
|
||||
// @ts-expect-error a refusal-code probe reads no payload, so it cannot carry a reader
|
||||
read: workspaceRowsReader
|
||||
}
|
||||
|
||||
// @ts-expect-error 'require-result-or-throw' has no value to return without a reader
|
||||
export const fenceDecodingWithoutReader: RequireResultRpcDefinition<
|
||||
'worktree.ps',
|
||||
'rows',
|
||||
WorkspaceRows,
|
||||
'on-settle'
|
||||
> = {
|
||||
name: 'fence.decodingWithoutReader',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
|
||||
// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary
|
||||
export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine'
|
||||
|
||||
// A reader for a payload no acceptance policy here admits, i.e. one belonging to some other
|
||||
// family's shape.
|
||||
const fenceTextReader: RpcCompatibleReader<string, 'text', string> = (raw) => ({
|
||||
compatible: true,
|
||||
variant: 'text',
|
||||
value: raw,
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
})
|
||||
|
||||
export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition<
|
||||
'worktree.ps',
|
||||
'text',
|
||||
string,
|
||||
'on-settle'
|
||||
> = {
|
||||
name: 'fence.objectPolicyWrongReader',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle',
|
||||
// @ts-expect-error the policy admits a non-null object, not the string this reader expects
|
||||
read: fenceTextReader
|
||||
}
|
||||
|
||||
export const fenceDefineRejectsMismatch = defineRpcOperation({
|
||||
name: 'fence.defineRejectsMismatch',
|
||||
method: 'worktree.ps',
|
||||
// @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle',
|
||||
// @ts-expect-error ... and the reader it would need is exactly what the probe overload bans
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
// @ts-expect-error only generated catalog method names are addressable
|
||||
export const fenceUnknownMethod: RpcMethodName = 'worktree.nope'
|
||||
|
||||
// The send-side params type. z.output (what the handler receives) and z.input (what the
|
||||
// coercing builders admit) are both wrong for a sender in opposite directions, so these pin
|
||||
// the two failures a regression to either one would reintroduce.
|
||||
|
||||
// `query` and `limit` carry .default(), so a sender may leave them out. Under z.output both
|
||||
// read as required and this line stops compiling.
|
||||
export const fenceOmitsDefaultedField: RpcSendParams<'files.searchPaths'> = { worktree: 'w' }
|
||||
|
||||
export const fenceRejectsWrongFieldType: RpcSendParams<'files.searchPaths'> = {
|
||||
// @ts-expect-error z.input of a z.unknown().transform builder admits any value; this does not
|
||||
worktree: 42
|
||||
}
|
||||
|
||||
// @ts-expect-error `worktree` has neither a default nor an optional marker
|
||||
export const fenceKeepsRequiredField: RpcSendParams<'files.searchPaths'> = { query: 'x' }
|
||||
|
||||
// Catalog-wide: anything a handler could have been handed is something a sender may write.
|
||||
// A method that ever resolves tighter than its parsed shape lands in this union.
|
||||
declare const fenceTighterThanParsed: {
|
||||
[Method in RpcMethodName]: RpcParams<Method> extends RpcSendParams<Method> ? never : Method
|
||||
}[RpcMethodName] & {}
|
||||
export const fenceNoTighterMethod: never = fenceTighterThanParsed
|
||||
|
||||
// z.input collapses every coercing builder to `unknown`. Only plugins.panelAction may be
|
||||
// unknown, because its schema is literally z.unknown().
|
||||
declare const fenceUnknownParams: {
|
||||
[Method in RpcMethodName]: unknown extends RpcSendParams<Method> ? Method : never
|
||||
}[RpcMethodName] & {}
|
||||
export const fenceOnlyDeclaredUnknown: 'plugins.panelAction' = fenceUnknownParams
|
||||
|
||||
export async function fenceBarrierAndParams(): Promise<void> {
|
||||
await runRpcOperation(
|
||||
client,
|
||||
// @ts-expect-error this family interprets after all requests, so it has no on-settle run
|
||||
workspaceListAtBarrier,
|
||||
{}
|
||||
)
|
||||
startRpcOperation(
|
||||
client,
|
||||
// @ts-expect-error an on-settle family must not be parked behind someone else's barrier
|
||||
worktreePsProbe,
|
||||
{}
|
||||
)
|
||||
await runRpcOperation(
|
||||
client,
|
||||
workspaceListOrNull,
|
||||
// @ts-expect-error worktree.ps takes a numeric limit
|
||||
{ limit: 'ten' }
|
||||
)
|
||||
}
|
||||
|
||||
export async function fenceVerdictTypes(): Promise<void> {
|
||||
// @ts-expect-error the probe's policy yields a boolean, not the other family's rows
|
||||
const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {})
|
||||
void rows
|
||||
}
|
||||
|
||||
// @ts-expect-error the public descriptor also requires decoding, even without the factory
|
||||
export const fenceManualWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
'require-result-or-throw',
|
||||
'rows',
|
||||
WorkspaceRows,
|
||||
'on-settle'
|
||||
> = {
|
||||
name: 'fence.manual',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
|
||||
// @ts-expect-error widening the policy cannot disconnect it from its required reader
|
||||
export const fenceBroadWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
RpcAcceptanceName,
|
||||
'rows',
|
||||
WorkspaceRows,
|
||||
'on-settle'
|
||||
> = {
|
||||
name: 'fence.broad',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
read: undefined
|
||||
}
|
||||
|
||||
// @ts-expect-error object acceptance must decode, just like require-result acceptance
|
||||
export const fenceObjectWithoutReader: RpcOperation<
|
||||
'worktree.ps',
|
||||
'object-result-or-null',
|
||||
'rows',
|
||||
WorkspaceRows,
|
||||
'on-settle'
|
||||
> = {
|
||||
name: 'fence.object',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle'
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { RpcMethodName } from './rpc-params-contract'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from './types'
|
||||
|
||||
// An operation descriptor fixes the method, the acceptance policy and the interpretation
|
||||
// barrier at definition time. Per-call freedom over those three is what produced acceptance
|
||||
// drift and settlement-order drift across mobile's RPC call sites, so none of them is a
|
||||
// parameter of any send helper.
|
||||
|
||||
/** One of the named policies in rpc-acceptance-policies.ts, chosen per operation family. */
|
||||
export type RpcAcceptanceName =
|
||||
| 'require-result-or-throw'
|
||||
| 'object-result-or-null'
|
||||
| 'method-not-found-refusal'
|
||||
| 'streaming-opener'
|
||||
|
||||
/** Where a settled reply may become a value or a throw. */
|
||||
export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests'
|
||||
|
||||
export type RpcDecodeIssue = { readonly path: string; readonly message: string }
|
||||
|
||||
/** Bounded salvage diagnostics for a reply that decoded with parts dropped. */
|
||||
export type RpcSalvageReport = {
|
||||
readonly droppedPaths: readonly string[]
|
||||
readonly droppedCount: number
|
||||
}
|
||||
|
||||
export type RpcReadResult<Variant extends string, Value> =
|
||||
| {
|
||||
readonly compatible: true
|
||||
readonly variant: Variant
|
||||
readonly value: Value
|
||||
readonly salvage: RpcSalvageReport
|
||||
}
|
||||
| { readonly compatible: false; readonly issues: readonly RpcDecodeIssue[] }
|
||||
|
||||
/** Reads the payload its acceptance policy admits into one declared semantic variant. */
|
||||
export type RpcCompatibleReader<Raw, Variant extends string, Value> = (
|
||||
raw: Raw
|
||||
) => RpcReadResult<Variant, Value>
|
||||
|
||||
export type RpcStreamOpenerReply = RpcSuccess & { streaming: true }
|
||||
|
||||
// Only a fulfilled outer envelope is classified. Transport rejection stays on the promise
|
||||
// channel, so an operation in a Promise.all still fails the group immediately instead of
|
||||
// waiting for a peer and letting a later policy surface a different error.
|
||||
export type RpcRequestOutcome<Variant extends string, Value> =
|
||||
| {
|
||||
readonly kind: 'outer-refused'
|
||||
readonly error: RpcFailure['error']
|
||||
readonly raw: RpcResponse
|
||||
}
|
||||
| {
|
||||
readonly kind: 'decoded'
|
||||
readonly variant: Variant
|
||||
readonly value: Value
|
||||
readonly raw: RpcResponse
|
||||
readonly salvage: RpcSalvageReport
|
||||
}
|
||||
| {
|
||||
readonly kind: 'incompatible'
|
||||
readonly raw: RpcResponse
|
||||
readonly issues: readonly RpcDecodeIssue[]
|
||||
}
|
||||
|
||||
export type RpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Acceptance extends RpcAcceptanceName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = {
|
||||
/** Family name, not the method: two families may share a method with different acceptance. */
|
||||
readonly name: string
|
||||
readonly method: Method
|
||||
readonly barrier: Barrier
|
||||
} & {
|
||||
[Policy in RpcAcceptanceName]: {
|
||||
readonly acceptance: Policy
|
||||
readonly read: Policy extends 'require-result-or-throw' | 'object-result-or-null'
|
||||
? RpcCompatibleReader<unknown, Variant, Value>
|
||||
: undefined
|
||||
}
|
||||
}[Acceptance]
|
||||
|
||||
// Internal interpreter view; public send APIs retain the policy/reader correlation.
|
||||
export type AnyRpcOperation = Pick<
|
||||
RpcOperation<RpcMethodName, RpcAcceptanceName, string, unknown, RpcInterpretationBarrier>,
|
||||
'name' | 'method' | 'acceptance' | 'barrier'
|
||||
> & { readonly read: RpcCompatibleReader<unknown, string, unknown> | undefined }
|
||||
|
||||
/** The verdict the declared policy yields. Not a per-call choice. */
|
||||
export type RpcVerdict<
|
||||
Acceptance extends RpcAcceptanceName,
|
||||
Value
|
||||
> = Acceptance extends 'require-result-or-throw'
|
||||
? Value
|
||||
: Acceptance extends 'object-result-or-null'
|
||||
? Value | null
|
||||
: Acceptance extends 'method-not-found-refusal'
|
||||
? boolean
|
||||
: Acceptance extends 'streaming-opener'
|
||||
? RpcStreamOpenerReply | null
|
||||
: never
|
||||
|
||||
export type RpcOperationSettlement<Variant extends string, Value> =
|
||||
| { readonly status: 'fulfilled'; readonly outcome: RpcRequestOutcome<Variant, Value> }
|
||||
| { readonly status: 'rejected'; readonly error: unknown }
|
||||
|
||||
type RpcOperationDefinition<
|
||||
Method extends RpcMethodName,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = {
|
||||
name: string
|
||||
method: Method
|
||||
barrier: Barrier
|
||||
}
|
||||
|
||||
export type RequireResultRpcDefinition<
|
||||
Method extends RpcMethodName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = RpcOperationDefinition<Method, Barrier> & {
|
||||
acceptance: 'require-result-or-throw'
|
||||
read: RpcCompatibleReader<unknown, Variant, Value>
|
||||
}
|
||||
|
||||
export type ObjectResultRpcDefinition<
|
||||
Method extends RpcMethodName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = RpcOperationDefinition<Method, Barrier> & {
|
||||
acceptance: 'object-result-or-null'
|
||||
// Raw is the non-null object rpcObjectResultOrNull admits; anything else is incompatible.
|
||||
read: RpcCompatibleReader<Record<string, unknown>, Variant, Value>
|
||||
}
|
||||
|
||||
export type CapabilityProbeRpcDefinition<
|
||||
Method extends RpcMethodName,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = RpcOperationDefinition<Method, Barrier> & {
|
||||
acceptance: 'method-not-found-refusal'
|
||||
/** A probe answers from the refusal code alone, so a reader would have nothing to read. */
|
||||
read?: never
|
||||
}
|
||||
|
||||
export type StreamOpenerRpcDefinition<
|
||||
Method extends RpcMethodName,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
> = RpcOperationDefinition<Method, Barrier> & {
|
||||
acceptance: 'streaming-opener'
|
||||
/** The opener's value is the reply itself; frames arrive on the subscription, not here. */
|
||||
read?: never
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
|
||||
import { captureRpcOperationSettlement, defineRpcOperation } from './rpc-operation'
|
||||
import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader'
|
||||
import {
|
||||
WORKSPACE_ROWS_SCHEMA,
|
||||
rpcSuccess,
|
||||
workspaceRowsReader
|
||||
} from './rpc-operation-test-families'
|
||||
|
||||
const SALVAGING_ROWS_SCHEMA = z.object({
|
||||
worktrees: salvagingArray(z.object({ id: z.string() }))
|
||||
})
|
||||
|
||||
const salvagingReader = rpcResultVariant('rows', SALVAGING_ROWS_SCHEMA)
|
||||
|
||||
describe('a single-variant reader', () => {
|
||||
it('decodes a matching payload and reports nothing dropped', () => {
|
||||
expect(workspaceRowsReader({ worktrees: [{ id: 'w1' }] })).toEqual({
|
||||
compatible: true,
|
||||
variant: 'rows',
|
||||
value: { worktrees: [{ id: 'w1' }] },
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports dotted issue paths for a payload it cannot read', () => {
|
||||
const result = workspaceRowsReader({ worktrees: [{ id: 1 }] })
|
||||
|
||||
expect(result.compatible).toBe(false)
|
||||
if (result.compatible) {
|
||||
throw new Error('expected an incompatible read')
|
||||
}
|
||||
expect(result.issues).toEqual([{ path: 'worktrees.0.id', message: expect.any(String) }])
|
||||
})
|
||||
|
||||
it('carries the salvage report when the schema drops an element', () => {
|
||||
const result = salvagingReader({ worktrees: [{ id: 'w1' }, { id: 7 }] })
|
||||
|
||||
expect(result).toEqual({
|
||||
compatible: true,
|
||||
variant: 'rows',
|
||||
value: { worktrees: [{ id: 'w1' }] },
|
||||
// zod-salvage reports the path relative to the salvaging container, not the envelope.
|
||||
salvage: { droppedPaths: ['1'], droppedCount: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
// zod-salvage keeps its collector at module level, so a leak here would blame the next
|
||||
// reply for the previous one's drops.
|
||||
it('does not leak drop diagnostics into the next read', () => {
|
||||
salvagingReader({ worktrees: [{ id: 7 }] })
|
||||
|
||||
expect(salvagingReader({ worktrees: [{ id: 'w1' }] })).toMatchObject({
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds the issues it reports and says how many it dropped', () => {
|
||||
const wide = { worktrees: Array.from({ length: 25 }, () => ({ id: 1 })) }
|
||||
const result = workspaceRowsReader(wide)
|
||||
|
||||
if (result.compatible) {
|
||||
throw new Error('expected an incompatible read')
|
||||
}
|
||||
expect(result.issues).toHaveLength(21)
|
||||
expect(result.issues[20]).toEqual({ path: '', message: '5 further issues omitted' })
|
||||
})
|
||||
|
||||
// The caveat that comes with zod-salvage: it wraps a synchronous parse only. An async
|
||||
// schema must read as incompatible rather than leaking a promise into the outcome.
|
||||
it('reads an async schema as incompatible instead of leaking a promise', () => {
|
||||
const asyncReader = rpcResultVariant(
|
||||
'rows',
|
||||
z.object({ id: z.string() }).refine(async () => true)
|
||||
)
|
||||
|
||||
const result = asyncReader({ id: 'w1' })
|
||||
|
||||
expect(result.compatible).toBe(false)
|
||||
if (result.compatible) {
|
||||
throw new Error('expected an incompatible read')
|
||||
}
|
||||
expect(result.issues[0].message).toContain('synchronous parse')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a multi-variant reader', () => {
|
||||
const reader = rpcResultVariants<'rows' | 'legacy-array', unknown>([
|
||||
workspaceRowsReader,
|
||||
rpcResultVariant('legacy-array', z.array(z.object({ id: z.string() })))
|
||||
])
|
||||
|
||||
it('takes the first declared variant that reads', () => {
|
||||
expect(reader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ variant: 'rows' })
|
||||
})
|
||||
|
||||
it('falls through to a later variant', () => {
|
||||
expect(reader([{ id: 'w1' }])).toMatchObject({
|
||||
variant: 'legacy-array',
|
||||
value: [{ id: 'w1' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('tags every variant it tried when none of them reads', () => {
|
||||
const result = reader('neither shape')
|
||||
|
||||
if (result.compatible) {
|
||||
throw new Error('expected an incompatible read')
|
||||
}
|
||||
expect(result.issues.map((issue) => issue.path)).toEqual(['rows', 'legacy-array'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('salvage through a descriptor', () => {
|
||||
const salvagingList = defineRpcOperation({
|
||||
name: 'test.salvagingWorkspaceList',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
read: salvagingReader
|
||||
})
|
||||
|
||||
it('reports the dropped paths on the decoded outcome', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockResolvedValue(rpcSuccess({ worktrees: [{ id: 'w1' }, { id: 7 }] }))
|
||||
|
||||
const settlement = await captureRpcOperationSettlement(session, salvagingList, {})
|
||||
|
||||
expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({
|
||||
kind: 'decoded',
|
||||
value: { worktrees: [{ id: 'w1' }] },
|
||||
salvage: { droppedPaths: ['1'], droppedCount: 1 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shared workspace schema', () => {
|
||||
it('is the strict shape the salvaging variant relaxes', () => {
|
||||
expect(WORKSPACE_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(false)
|
||||
expect(SALVAGING_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod'
|
||||
import { collectSalvageDrops } from '../../../src/shared/zod-salvage'
|
||||
import type { RpcCompatibleReader, RpcDecodeIssue } from './rpc-operation-contract'
|
||||
|
||||
const MAX_REPORTED_DECODE_ISSUES = 20
|
||||
|
||||
/** A reader that names the semantic variant it decodes, so a combinator can tag its issues. */
|
||||
export type NamedRpcResultReader<Variant extends string, Value> = RpcCompatibleReader<
|
||||
unknown,
|
||||
Variant,
|
||||
Value
|
||||
> & { readonly variant: Variant }
|
||||
|
||||
/** Builds a compatible reader for one semantic variant of a reply payload. */
|
||||
export function rpcResultVariant<Variant extends string, Schema extends z.ZodType>(
|
||||
variant: Variant,
|
||||
schema: Schema
|
||||
): NamedRpcResultReader<Variant, z.output<Schema>> {
|
||||
const read: RpcCompatibleReader<unknown, Variant, z.output<Schema>> = (raw) => {
|
||||
try {
|
||||
// Why: zod-salvage holds module-level collector state and wraps a *synchronous*
|
||||
// parse only; safeParse throws on an async schema, which reads as incompatible.
|
||||
const parsed = collectSalvageDrops(() => schema.safeParse(raw))
|
||||
if (!parsed.value.success) {
|
||||
return { compatible: false, issues: decodeIssues(parsed.value.error) }
|
||||
}
|
||||
return {
|
||||
compatible: true,
|
||||
variant,
|
||||
value: parsed.value.data as z.output<Schema>,
|
||||
salvage: { droppedPaths: parsed.droppedPaths, droppedCount: parsed.droppedCount }
|
||||
}
|
||||
} catch (error) {
|
||||
return { compatible: false, issues: [{ path: '', message: describeThrow(error) }] }
|
||||
}
|
||||
}
|
||||
return Object.assign(read, { variant })
|
||||
}
|
||||
|
||||
/** Tries each variant in declared order and takes the first that reads. */
|
||||
export function rpcResultVariants<Variant extends string, Value>(
|
||||
readers: readonly [
|
||||
NamedRpcResultReader<Variant, Value>,
|
||||
...NamedRpcResultReader<Variant, Value>[]
|
||||
]
|
||||
): RpcCompatibleReader<unknown, Variant, Value> {
|
||||
return (raw) => {
|
||||
const issues: RpcDecodeIssue[] = []
|
||||
for (const reader of readers) {
|
||||
const result = reader(raw)
|
||||
if (result.compatible) {
|
||||
return result
|
||||
}
|
||||
for (const issue of result.issues) {
|
||||
issues.push({ path: joinPath(reader.variant, issue.path), message: issue.message })
|
||||
}
|
||||
}
|
||||
return { compatible: false, issues: boundIssues(issues) }
|
||||
}
|
||||
}
|
||||
|
||||
function decodeIssues(error: z.ZodError): RpcDecodeIssue[] {
|
||||
return boundIssues(
|
||||
error.issues.map((issue) => ({
|
||||
path: issue.path.map((segment) => String(segment)).join('.'),
|
||||
message: issue.message
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a hostile or very foreign reply can issue per element; report a bounded sample and
|
||||
// say how many were dropped rather than letting the diagnostic grow with the payload.
|
||||
function boundIssues(issues: readonly RpcDecodeIssue[]): RpcDecodeIssue[] {
|
||||
if (issues.length <= MAX_REPORTED_DECODE_ISSUES) {
|
||||
return [...issues]
|
||||
}
|
||||
return [
|
||||
...issues.slice(0, MAX_REPORTED_DECODE_ISSUES),
|
||||
{ path: '', message: `${issues.length - MAX_REPORTED_DECODE_ISSUES} further issues omitted` }
|
||||
]
|
||||
}
|
||||
|
||||
function joinPath(variant: string, path: string): string {
|
||||
return path ? `${variant}.${path}` : variant
|
||||
}
|
||||
|
||||
function describeThrow(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from 'zod'
|
||||
import type { RpcResponse } from './types'
|
||||
import type { RpcCompatibleReader } from './rpc-operation-contract'
|
||||
import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader'
|
||||
import { defineRpcOperation } from './rpc-operation'
|
||||
|
||||
// Operation families used by the rpc-operation suites and by the compile fence. Kept in one
|
||||
// place so the tests and the fence assert against the same descriptors, and so tsc sees them
|
||||
// (the app tsconfig excludes *.test.ts). No app code imports this module.
|
||||
|
||||
export const WORKSPACE_ROWS_SCHEMA = z.object({
|
||||
worktrees: z.array(z.object({ id: z.string() }))
|
||||
})
|
||||
|
||||
const LEGACY_WORKSPACE_ROWS_SCHEMA = z.array(z.object({ id: z.string() }))
|
||||
|
||||
export type WorkspaceRows = z.output<typeof WORKSPACE_ROWS_SCHEMA>
|
||||
export type LegacyWorkspaceRows = z.output<typeof LEGACY_WORKSPACE_ROWS_SCHEMA>
|
||||
|
||||
export const workspaceRowsReader = rpcResultVariant('rows', WORKSPACE_ROWS_SCHEMA)
|
||||
|
||||
/** Two semantic variants: the modern envelope, then a host that answered a bare array. */
|
||||
export const workspaceRowsOrLegacyReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'rows' | 'legacy-array',
|
||||
WorkspaceRows | LegacyWorkspaceRows
|
||||
> = rpcResultVariants<'rows' | 'legacy-array', WorkspaceRows | LegacyWorkspaceRows>([
|
||||
workspaceRowsReader,
|
||||
rpcResultVariant('legacy-array', LEGACY_WORKSPACE_ROWS_SCHEMA)
|
||||
])
|
||||
|
||||
export const workspaceListOrThrow = defineRpcOperation({
|
||||
name: 'test.workspaceListOrThrow',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'on-settle',
|
||||
read: workspaceRowsOrLegacyReader
|
||||
})
|
||||
|
||||
// Same method, a different family: main's callers disagreed about acceptance, so both rules
|
||||
// stay named rather than being unified behind one descriptor.
|
||||
export const workspaceListOrNull = defineRpcOperation({
|
||||
name: 'test.workspaceListOrNull',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'on-settle',
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
export const worktreePsProbe = defineRpcOperation({
|
||||
name: 'test.worktreePsProbe',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'on-settle'
|
||||
})
|
||||
|
||||
export const terminalStreamOpener = defineRpcOperation({
|
||||
name: 'test.terminalStreamOpener',
|
||||
method: 'terminal.subscribe',
|
||||
acceptance: 'streaming-opener',
|
||||
barrier: 'on-settle'
|
||||
})
|
||||
|
||||
export const workspaceListAtBarrier = defineRpcOperation({
|
||||
name: 'test.workspaceListAtBarrier',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'require-result-or-throw',
|
||||
barrier: 'after-all-requests',
|
||||
read: workspaceRowsReader
|
||||
})
|
||||
|
||||
export const terminalListAtBarrier = defineRpcOperation({
|
||||
name: 'test.terminalListAtBarrier',
|
||||
method: 'terminal.list',
|
||||
acceptance: 'object-result-or-null',
|
||||
barrier: 'after-all-requests',
|
||||
read: rpcResultVariant('terminals', z.object({ terminals: z.array(z.unknown()) }))
|
||||
})
|
||||
|
||||
export const worktreePsProbeAtBarrier = defineRpcOperation({
|
||||
name: 'test.worktreePsProbeAtBarrier',
|
||||
method: 'worktree.ps',
|
||||
acceptance: 'method-not-found-refusal',
|
||||
barrier: 'after-all-requests'
|
||||
})
|
||||
|
||||
export function rpcSuccess(result: unknown, streaming?: true): RpcResponse {
|
||||
return {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-1' },
|
||||
...(streaming ? { streaming } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function rpcRefusal(code: string, message = 'Nope'): RpcResponse {
|
||||
return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from './types'
|
||||
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
|
||||
import {
|
||||
createStableLogicalRpcClient,
|
||||
isLogicalClientCutoverError
|
||||
} from './stable-logical-rpc-client'
|
||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import {
|
||||
RpcIncompatibleReplyError,
|
||||
isRpcIncompatibleReplyError
|
||||
} from './rpc-incompatible-reply-error'
|
||||
import { captureRpcOperationSettlement, runRpcOperation } from './rpc-operation'
|
||||
import {
|
||||
rpcRefusal,
|
||||
rpcSuccess,
|
||||
terminalListAtBarrier,
|
||||
terminalStreamOpener,
|
||||
workspaceListOrNull,
|
||||
workspaceListOrThrow,
|
||||
worktreePsProbe
|
||||
} from './rpc-operation-test-families'
|
||||
|
||||
function connectedSession(response?: RpcResponse): FakeSession {
|
||||
const session = new FakeSession('connected')
|
||||
if (response) {
|
||||
session.sendRequest.mockResolvedValue(response)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
const rows = { worktrees: [{ id: 'w1' }] }
|
||||
|
||||
describe('request classification', () => {
|
||||
it('decodes a compatible reply, naming the variant and keeping the raw envelope', async () => {
|
||||
const response = rpcSuccess(rows)
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(response),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement).toEqual({
|
||||
status: 'fulfilled',
|
||||
outcome: {
|
||||
kind: 'decoded',
|
||||
variant: 'rows',
|
||||
value: rows,
|
||||
raw: response,
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('names the legacy variant when the host answered the older shape', async () => {
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(rpcSuccess([{ id: 'w1' }])),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('decoded')
|
||||
expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({
|
||||
variant: 'legacy-array',
|
||||
value: [{ id: 'w1' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a refusal as outer-refused rather than throwing', async () => {
|
||||
const response = rpcRefusal('runtime_error', 'boom')
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(response),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement).toEqual({
|
||||
status: 'fulfilled',
|
||||
outcome: {
|
||||
kind: 'outer-refused',
|
||||
error: { code: 'runtime_error', message: 'boom' },
|
||||
raw: response
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies a reply the reader cannot read as incompatible, with bounded issues', async () => {
|
||||
const response = rpcSuccess({ worktrees: [{ id: 7 }] })
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(response),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement.status).toBe('fulfilled')
|
||||
if (settlement.status !== 'fulfilled' || settlement.outcome.kind !== 'incompatible') {
|
||||
throw new Error('expected an incompatible outcome')
|
||||
}
|
||||
expect(settlement.outcome.raw).toBe(response)
|
||||
expect(settlement.outcome.issues.map((issue) => issue.path)).toContain('rows.worktrees.0.id')
|
||||
})
|
||||
|
||||
it('treats a reply that is not an object as incompatible for the nullable family', async () => {
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(rpcSuccess('not an object')),
|
||||
workspaceListOrNull,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('treats a throwing reader as incompatible, never as a transport failure', async () => {
|
||||
const exploding = {
|
||||
...workspaceListOrThrow,
|
||||
read: () => {
|
||||
throw new Error('reader exploded')
|
||||
}
|
||||
}
|
||||
const settlement = await captureRpcOperationSettlement(
|
||||
connectedSession(rpcSuccess(rows)),
|
||||
exploding,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transport rejection stays on the promise channel', () => {
|
||||
it('rejects with the original error object', async () => {
|
||||
const error = new Error('socket closed')
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockRejectedValue(error)
|
||||
|
||||
await expect(runRpcOperation(session, workspaceListOrThrow, {})).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('keeps a delivery-unknown mark readable through the descriptor', async () => {
|
||||
const error = markRpcDeliveryUnknown(new Error('socket closed before response'))
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockRejectedValue(error)
|
||||
|
||||
const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch(
|
||||
(thrown: unknown) => thrown
|
||||
)
|
||||
|
||||
expect(caught).toBe(error)
|
||||
expect(isRpcDeliveryUnknown(caught)).toBe(true)
|
||||
})
|
||||
|
||||
// The cutover predicate matches class or exact message because instanceof misses across
|
||||
// bundle copies; a clone from another copy must still read as a cutover through the descriptor.
|
||||
it('keeps a cutover error from another bundle copy recognisable', async () => {
|
||||
class ForeignBundleCutoverError extends Error {}
|
||||
const error = new ForeignBundleCutoverError('RPC interrupted by connection migration')
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockRejectedValue(error)
|
||||
|
||||
const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch(
|
||||
(thrown: unknown) => thrown
|
||||
)
|
||||
|
||||
expect(caught).toBe(error)
|
||||
expect(isLogicalClientCutoverError(caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails a Promise.all group immediately instead of waiting for a stalled peer', async () => {
|
||||
const error = new Error('socket closed')
|
||||
const failing = new FakeSession('connected')
|
||||
failing.sendRequest.mockRejectedValue(error)
|
||||
const stalled = new FakeSession('connected')
|
||||
stalled.sendRequest.mockReturnValue(new Promise<RpcResponse>(() => {}))
|
||||
|
||||
const group = Promise.all([
|
||||
runRpcOperation(failing, workspaceListOrThrow, {}),
|
||||
runRpcOperation(stalled, workspaceListOrThrow, {})
|
||||
])
|
||||
const raced = await Promise.race([
|
||||
group.then(
|
||||
() => 'resolved' as const,
|
||||
(caught: unknown) => caught
|
||||
),
|
||||
new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50))
|
||||
])
|
||||
|
||||
expect(raced).toBe(error)
|
||||
})
|
||||
|
||||
it('only captures a rejection when a caller names the all-settled helper', async () => {
|
||||
const error = new Error('socket closed')
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockRejectedValue(error)
|
||||
|
||||
await expect(captureRpcOperationSettlement(session, workspaceListOrThrow, {})).resolves.toEqual(
|
||||
{ status: 'rejected', error }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the send path', () => {
|
||||
it('carries the worktree.ps capability stamp, because it goes through the logical client', async () => {
|
||||
const session = connectedSession(rpcSuccess(rows))
|
||||
const logical = createStableLogicalRpcClient(session, 'lan')
|
||||
|
||||
await runRpcOperation(logical, workspaceListOrThrow, { limit: 500 })
|
||||
|
||||
expect(session.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.ps',
|
||||
{ limit: 500, supportsWorktreeVisibilitySourceDefaults: true },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the caller params object untouched for a method with no projection', async () => {
|
||||
const session = connectedSession(rpcSuccess({ terminals: [] }))
|
||||
const logical = createStableLogicalRpcClient(session, 'lan')
|
||||
const params = { worktree: 'w1' }
|
||||
|
||||
await captureRpcOperationSettlement(logical, terminalListAtBarrier, params, {
|
||||
timeoutMs: 1234
|
||||
})
|
||||
|
||||
expect(session.sendRequest.mock.calls[0][0]).toBe('terminal.list')
|
||||
expect(session.sendRequest.mock.calls[0][1]).toBe(params)
|
||||
expect(session.sendRequest.mock.calls[0][2]).toEqual({ timeoutMs: 1234 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('acceptance is a property of the family', () => {
|
||||
const refusal = rpcRefusal('method_not_found', 'no such method')
|
||||
|
||||
it('surfaces the refusal as a coded error for the throwing family', async () => {
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(refusal), workspaceListOrThrow, {})
|
||||
).rejects.toThrow('method_not_found: no such method')
|
||||
})
|
||||
|
||||
it('answers null to the same refusal for the nullable family', async () => {
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(refusal), workspaceListOrNull, {})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers true to the same refusal for the capability probe', async () => {
|
||||
await expect(runRpcOperation(connectedSession(refusal), worktreePsProbe, {})).resolves.toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the probe false for another refusal code and for a success', async () => {
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(rpcRefusal('runtime_error')), worktreePsProbe, {})
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(rpcSuccess(rows)), worktreePsProbe, {})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns the decoded value for the throwing family', async () => {
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(rpcSuccess(rows)), workspaceListOrThrow, {})
|
||||
).resolves.toEqual(rows)
|
||||
})
|
||||
|
||||
it('returns the reply itself only when it opened a stream', async () => {
|
||||
const opener = rpcSuccess({ subscriptionId: 's1' }, true)
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(opener), terminalStreamOpener, { terminal: 't1' })
|
||||
).resolves.toBe(opener)
|
||||
await expect(
|
||||
runRpcOperation(
|
||||
connectedSession(rpcSuccess({ subscriptionId: 's1' })),
|
||||
terminalStreamOpener,
|
||||
{
|
||||
terminal: 't1'
|
||||
}
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(rpcRefusal('runtime_error')), terminalStreamOpener, {
|
||||
terminal: 't1'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('an incompatible reply', () => {
|
||||
const incompatible = rpcSuccess({ worktrees: [{ id: 7 }] })
|
||||
|
||||
it('throws a named incompatible-reply error for the throwing family', async () => {
|
||||
const caught = await runRpcOperation(
|
||||
connectedSession(incompatible),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
).catch((thrown: unknown) => thrown)
|
||||
|
||||
expect(caught).toBeInstanceOf(RpcIncompatibleReplyError)
|
||||
expect(isRpcIncompatibleReplyError(caught)).toBe(true)
|
||||
expect((caught as RpcIncompatibleReplyError).method).toBe('worktree.ps')
|
||||
expect((caught as RpcIncompatibleReplyError).operationName).toBe('test.workspaceListOrThrow')
|
||||
expect((caught as RpcIncompatibleReplyError).issues.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// A reply nobody can read says nothing about what the host did, so it must not look like
|
||||
// either of the two errors the mutation retry loops replay on.
|
||||
it('authorizes no retry', async () => {
|
||||
const caught = await runRpcOperation(
|
||||
connectedSession(incompatible),
|
||||
workspaceListOrThrow,
|
||||
{}
|
||||
).catch((thrown: unknown) => thrown)
|
||||
|
||||
expect(isRpcDeliveryUnknown(caught)).toBe(false)
|
||||
expect(isLogicalClientCutoverError(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers null for the nullable family', async () => {
|
||||
await expect(
|
||||
runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a descriptor', () => {
|
||||
it('cannot have its policy or barrier swapped at runtime', () => {
|
||||
expect(Object.isFrozen(workspaceListOrThrow)).toBe(true)
|
||||
expect(() => {
|
||||
;(workspaceListOrThrow as { acceptance: string }).acceptance = 'object-result-or-null'
|
||||
}).toThrow(TypeError)
|
||||
expect(() => {
|
||||
;(workspaceListOrThrow as { barrier: string }).barrier = 'after-all-requests'
|
||||
}).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port'
|
||||
import type { RpcMethodName, RpcSendParams } from './rpc-params-contract'
|
||||
import type { RpcResponse } from './types'
|
||||
import {
|
||||
isMethodNotFoundRefusal,
|
||||
isStreamingOpenerReply,
|
||||
requireRpcResultOrThrowCodedError,
|
||||
rpcObjectResultOrNull
|
||||
} from './rpc-acceptance-policies'
|
||||
import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error'
|
||||
import type {
|
||||
AnyRpcOperation,
|
||||
CapabilityProbeRpcDefinition,
|
||||
ObjectResultRpcDefinition,
|
||||
RpcAcceptanceName,
|
||||
RpcCompatibleReader,
|
||||
RpcDecodeIssue,
|
||||
RpcInterpretationBarrier,
|
||||
RpcOperation,
|
||||
RpcOperationSettlement,
|
||||
RpcRequestOutcome,
|
||||
RpcSalvageReport,
|
||||
RequireResultRpcDefinition,
|
||||
StreamOpenerRpcDefinition,
|
||||
RpcVerdict
|
||||
} from './rpc-operation-contract'
|
||||
|
||||
const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 }
|
||||
|
||||
type RpcOperationDefinitionInput =
|
||||
| RequireResultRpcDefinition<RpcMethodName, string, unknown, RpcInterpretationBarrier>
|
||||
| ObjectResultRpcDefinition<RpcMethodName, string, unknown, RpcInterpretationBarrier>
|
||||
| CapabilityProbeRpcDefinition<RpcMethodName, RpcInterpretationBarrier>
|
||||
| StreamOpenerRpcDefinition<RpcMethodName, RpcInterpretationBarrier>
|
||||
|
||||
export function defineRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
>(
|
||||
definition: RequireResultRpcDefinition<Method, Variant, Value, Barrier>
|
||||
): RpcOperation<Method, 'require-result-or-throw', Variant, Value, Barrier>
|
||||
export function defineRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
>(
|
||||
definition: ObjectResultRpcDefinition<Method, Variant, Value, Barrier>
|
||||
): RpcOperation<Method, 'object-result-or-null', Variant, Value, Barrier>
|
||||
export function defineRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
>(
|
||||
definition: CapabilityProbeRpcDefinition<Method, Barrier>
|
||||
): RpcOperation<Method, 'method-not-found-refusal', 'accepted', unknown, Barrier>
|
||||
export function defineRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
>(
|
||||
definition: StreamOpenerRpcDefinition<Method, Barrier>
|
||||
): RpcOperation<Method, 'streaming-opener', 'stream-opened', unknown, Barrier>
|
||||
export function defineRpcOperation(definition: RpcOperationDefinitionInput): AnyRpcOperation {
|
||||
// Why: frozen so no call site can swap the policy or the barrier on a shared descriptor.
|
||||
return Object.freeze({
|
||||
name: definition.name,
|
||||
method: definition.method,
|
||||
acceptance: definition.acceptance,
|
||||
barrier: definition.barrier,
|
||||
// Why: classifyReply only ever hands a reader the payload its own policy admitted, so
|
||||
// the object policy's narrower parameter is sound to store as unknown.
|
||||
read: definition.read as RpcCompatibleReader<unknown, string, unknown> | undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Sends the operation without interpreting it; transport rejection stays on the promise. */
|
||||
async function request(
|
||||
client: UnvalidatedRpcRequestPort,
|
||||
operation: AnyRpcOperation,
|
||||
params: unknown,
|
||||
options?: SendRequestOptions
|
||||
): Promise<RpcRequestOutcome<string, unknown>> {
|
||||
// Why: no try/catch here. A transport failure must reach the caller as the original error
|
||||
// object — isLogicalClientCutoverError and isRpcDeliveryUnknown both die on a wrapper —
|
||||
// and an always-settled send would make Promise.all wait for a peer where today the group
|
||||
// fails immediately, letting a later policy surface a different error.
|
||||
const response = await client.sendRequest(operation.method, params, options)
|
||||
return classifyReply(operation, response)
|
||||
}
|
||||
|
||||
type AdmittedPayload =
|
||||
| { readonly admitted: true; readonly value: unknown }
|
||||
| { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] }
|
||||
|
||||
// The payload the operation's own acceptance policy admits from a fulfilled success.
|
||||
function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload {
|
||||
switch (operation.acceptance) {
|
||||
case 'object-result-or-null': {
|
||||
const object = rpcObjectResultOrNull(response)
|
||||
return object === null
|
||||
? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] }
|
||||
: { admitted: true, value: object }
|
||||
}
|
||||
case 'streaming-opener':
|
||||
return isStreamingOpenerReply(response)
|
||||
? { admitted: true, value: response }
|
||||
: { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] }
|
||||
default:
|
||||
// Reuses the policy rather than reading `.result` again; a success never throws here.
|
||||
return { admitted: true, value: requireRpcResultOrThrowCodedError(response) }
|
||||
}
|
||||
}
|
||||
|
||||
const READERLESS_VARIANTS: Record<string, string> = {
|
||||
'method-not-found-refusal': 'accepted',
|
||||
'streaming-opener': 'stream-opened'
|
||||
}
|
||||
|
||||
function classifyReply(
|
||||
operation: AnyRpcOperation,
|
||||
response: RpcResponse
|
||||
): RpcRequestOutcome<string, unknown> {
|
||||
if (!response.ok) {
|
||||
return { kind: 'outer-refused', error: response.error, raw: response }
|
||||
}
|
||||
const payload = admitPayload(operation, response)
|
||||
if (!payload.admitted) {
|
||||
return { kind: 'incompatible', raw: response, issues: payload.issues }
|
||||
}
|
||||
const read = operation.read
|
||||
if (!read) {
|
||||
return {
|
||||
kind: 'decoded',
|
||||
variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted',
|
||||
value: payload.value,
|
||||
raw: response,
|
||||
salvage: NOTHING_SALVAGED
|
||||
}
|
||||
}
|
||||
let result: ReturnType<typeof read>
|
||||
try {
|
||||
result = read(payload.value)
|
||||
} catch (error) {
|
||||
// A reader that throws is an incompatible reply, never a transport failure.
|
||||
return {
|
||||
kind: 'incompatible',
|
||||
raw: response,
|
||||
issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }]
|
||||
}
|
||||
}
|
||||
if (!result.compatible) {
|
||||
return { kind: 'incompatible', raw: response, issues: result.issues }
|
||||
}
|
||||
return {
|
||||
kind: 'decoded',
|
||||
variant: result.variant,
|
||||
value: result.value,
|
||||
raw: response,
|
||||
salvage: result.salvage
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the operation's declared acceptance policy. Private on purpose: there is no
|
||||
// free-standing callOrThrow, so no call site can pick a different rule for the same reply.
|
||||
function interpret(
|
||||
operation: AnyRpcOperation,
|
||||
settled: RpcRequestOutcome<string, unknown>
|
||||
): unknown {
|
||||
const acceptance: RpcAcceptanceName = operation.acceptance
|
||||
switch (acceptance) {
|
||||
case 'require-result-or-throw':
|
||||
if (settled.kind === 'outer-refused') {
|
||||
// Reuses the policy so the thrown `code: message` text cannot drift from main's.
|
||||
return requireRpcResultOrThrowCodedError(settled.raw)
|
||||
}
|
||||
if (settled.kind === 'incompatible') {
|
||||
throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues)
|
||||
}
|
||||
return settled.value
|
||||
case 'object-result-or-null':
|
||||
return settled.kind === 'decoded' ? settled.value : null
|
||||
case 'method-not-found-refusal':
|
||||
return settled.kind === 'outer-refused' ? isMethodNotFoundRefusal(settled.raw) : false
|
||||
case 'streaming-opener':
|
||||
return settled.kind === 'decoded' && isStreamingOpenerReply(settled.raw) ? settled.raw : null
|
||||
}
|
||||
}
|
||||
|
||||
function interpretSettlement(
|
||||
operation: AnyRpcOperation,
|
||||
settlement: RpcOperationSettlement<string, unknown>
|
||||
): unknown {
|
||||
if (settlement.status === 'rejected') {
|
||||
// Why: rethrow the original object — isRpcDeliveryUnknown is a WeakSet on identity and
|
||||
// isLogicalClientCutoverError matches class or exact message; a wrapper loses both.
|
||||
throw settlement.error
|
||||
}
|
||||
return interpret(operation, settlement.outcome)
|
||||
}
|
||||
|
||||
/** Sends and interprets at the operation's own barrier. Only for barrier 'on-settle'. */
|
||||
export async function runRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Acceptance extends RpcAcceptanceName,
|
||||
Variant extends string,
|
||||
Value
|
||||
>(
|
||||
client: UnvalidatedRpcRequestPort,
|
||||
operation: RpcOperation<Method, Acceptance, Variant, Value, 'on-settle'>,
|
||||
params: RpcSendParams<Method>,
|
||||
options?: SendRequestOptions
|
||||
): Promise<RpcVerdict<Acceptance, Value>> {
|
||||
const outcome = await request(client, operation, params, options)
|
||||
return interpret(operation, outcome) as RpcVerdict<Acceptance, Value>
|
||||
}
|
||||
|
||||
/** The named opt-in to all-settled semantics. Yields an outcome, never a verdict: the
|
||||
* verdict still comes only from the declared policy, at the declared barrier. */
|
||||
export async function captureRpcOperationSettlement<
|
||||
Method extends RpcMethodName,
|
||||
Acceptance extends RpcAcceptanceName,
|
||||
Variant extends string,
|
||||
Value,
|
||||
Barrier extends RpcInterpretationBarrier
|
||||
>(
|
||||
client: UnvalidatedRpcRequestPort,
|
||||
operation: RpcOperation<Method, Acceptance, Variant, Value, Barrier>,
|
||||
params: RpcSendParams<Method>,
|
||||
options?: SendRequestOptions
|
||||
): Promise<RpcOperationSettlement<Variant, Value>> {
|
||||
try {
|
||||
const outcome = await request(client, operation, params, options)
|
||||
return { status: 'fulfilled', outcome: outcome as RpcRequestOutcome<Variant, Value> }
|
||||
} catch (error) {
|
||||
return { status: 'rejected', error }
|
||||
}
|
||||
}
|
||||
|
||||
export type PendingRpcOperation<Op extends AnyRpcOperation> = {
|
||||
readonly operation: Op
|
||||
readonly settlement: Promise<RpcOperationSettlement<string, unknown>>
|
||||
}
|
||||
|
||||
/** Starts a request whose interpretation is deferred to the barrier it declared. */
|
||||
export function startRpcOperation<
|
||||
Method extends RpcMethodName,
|
||||
Acceptance extends RpcAcceptanceName,
|
||||
Variant extends string,
|
||||
Value
|
||||
>(
|
||||
client: UnvalidatedRpcRequestPort,
|
||||
operation: RpcOperation<Method, Acceptance, Variant, Value, 'after-all-requests'>,
|
||||
params: RpcSendParams<Method>,
|
||||
options?: SendRequestOptions
|
||||
): PendingRpcOperation<RpcOperation<Method, Acceptance, Variant, Value, 'after-all-requests'>> {
|
||||
return {
|
||||
operation,
|
||||
settlement: captureRpcOperationSettlement(client, operation, params, options)
|
||||
}
|
||||
}
|
||||
|
||||
type RpcBarrierVerdicts<Pending extends readonly PendingRpcOperation<AnyRpcOperation>[]> = {
|
||||
[Index in keyof Pending]: Pending[Index] extends PendingRpcOperation<
|
||||
RpcOperation<RpcMethodName, infer Acceptance, string, infer Value, RpcInterpretationBarrier>
|
||||
>
|
||||
? RpcVerdict<Acceptance, Value>
|
||||
: never
|
||||
}
|
||||
|
||||
/** Awaits every raw request, then interprets in declared order. */
|
||||
export async function interpretAtRpcBarrier<
|
||||
Pending extends readonly PendingRpcOperation<AnyRpcOperation>[]
|
||||
>(pending: Pending): Promise<RpcBarrierVerdicts<Pending>> {
|
||||
// Why: interpreting as each request lands would let whichever peer failed first decide the
|
||||
// error the user sees and how long the screen spins. Declared order makes that a property
|
||||
// of the definition instead of a race.
|
||||
const settlements = await Promise.all(pending.map((entry) => entry.settlement))
|
||||
return pending.map((entry, index) =>
|
||||
interpretSettlement(entry.operation, settlements[index])
|
||||
) as RpcBarrierVerdicts<Pending>
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
// The schemas behind these types must never reach the bundle: requiredString is
|
||||
// z.unknown().transform(...), so a client-side parse coerces a non-string to ''
|
||||
// instead of rejecting it, silently changing the bytes on the wire.
|
||||
//
|
||||
// RpcSendParams is the outgoing type; RpcParams is the shape the handler sees after
|
||||
// parsing, which is not what a sender may write (see rpc-send-params.ts).
|
||||
export type {
|
||||
RpcMethodName,
|
||||
RpcParams
|
||||
} from '../../../src/shared/rpc-contract/rpc-params-catalog.generated'
|
||||
export type { RpcSendParams } from '../../../src/shared/rpc-contract/rpc-send-params'
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { extname, join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
UNVALIDATED_RPC_REQUEST_PORT_OWNERS,
|
||||
UNVALIDATED_RPC_REQUEST_PORT_PENDING,
|
||||
type UnvalidatedRpcRequestPortEntry
|
||||
} from './unvalidated-rpc-request-port-inventory'
|
||||
|
||||
/**
|
||||
* Ratchet for the raw RPC request port.
|
||||
*
|
||||
* `sendRequest` takes an unchecked method string and returns an envelope whose `result` is
|
||||
* `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which
|
||||
* is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by
|
||||
* the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So
|
||||
* the boundary is held as an inventory instead, and this test is what makes the inventory bind.
|
||||
*
|
||||
* Three failures, all of which mean "edit the list":
|
||||
* - a file reaches the port and is on neither list,
|
||||
* - a listed file no longer reaches it (stale entry — how allow-lists rot),
|
||||
* - a listed file's reference count went up.
|
||||
*
|
||||
* What this does NOT catch, all accepted:
|
||||
* - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an
|
||||
* unlisted one as a bare `(method: string) => Promise<RpcResponse>` and the receiver never
|
||||
* names the port. Only two senders are named here; a third wrapper needs adding by hand.
|
||||
* - Computed access — `client['send' + 'Request']` is not a literal in the AST.
|
||||
* - Which method a listed file sends, or what it does with the reply. The count is a ceiling
|
||||
* on how many times it reaches, nothing more.
|
||||
* - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a
|
||||
* test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is
|
||||
* scanned and listed.
|
||||
* A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the
|
||||
* port, which needs the call sites migrated first — the thing this list is counting down.
|
||||
*/
|
||||
|
||||
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
|
||||
const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
|
||||
const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port')
|
||||
|
||||
/** The port and its own inventory are not offenders; the ratchet does not police itself. */
|
||||
const SELF_FILES = new Set([
|
||||
'src/transport/unvalidated-rpc-request-port.ts',
|
||||
'src/transport/unvalidated-rpc-request-port-inventory.ts'
|
||||
])
|
||||
|
||||
/** The coalescing second sender: same unchecked string in, same unread envelope out. */
|
||||
const SECOND_SENDER = 'sendSingleFlightRequest'
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return entry.name === 'node_modules' ? [] : sourceFiles(path)
|
||||
}
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
function parse(path: string, source: string): ts.SourceFile {
|
||||
const extension = extname(path)
|
||||
return ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
)
|
||||
}
|
||||
|
||||
function targetsPortModule(path: string, node: ts.Node | undefined): boolean {
|
||||
if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) {
|
||||
return false
|
||||
}
|
||||
return resolve(path, '..', node.text) === portModule
|
||||
}
|
||||
|
||||
/** `client['sendRequest']` is one reach, not two: the element access already counted it. */
|
||||
function isCountedElementAccessArgument(node: ts.Node): boolean {
|
||||
const parent: ts.Node | undefined = node.parent
|
||||
return (
|
||||
parent !== undefined &&
|
||||
ts.isElementAccessExpression(parent) &&
|
||||
parent.argumentExpression === node
|
||||
)
|
||||
}
|
||||
|
||||
function declaresPortMember(node: ts.Node): boolean {
|
||||
if (
|
||||
!ts.isPropertySignature(node) &&
|
||||
!ts.isMethodSignature(node) &&
|
||||
!ts.isMethodDeclaration(node) &&
|
||||
!ts.isPropertyDeclaration(node) &&
|
||||
!ts.isPropertyAssignment(node) &&
|
||||
!ts.isShorthandPropertyAssignment(node)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const name = node.name
|
||||
return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest'
|
||||
}
|
||||
|
||||
/** How many times this file reaches the raw port directly. Comments never count: this is AST. */
|
||||
export function rawRequestPortReferences(path: string, source: string): number {
|
||||
let references = 0
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
(ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') ||
|
||||
(ts.isElementAccessExpression(node) &&
|
||||
ts.isStringLiteral(node.argumentExpression) &&
|
||||
node.argumentExpression.text === 'sendRequest') ||
|
||||
declaresPortMember(node) ||
|
||||
(ts.isStringLiteral(node) &&
|
||||
node.text === 'sendRequest' &&
|
||||
!isCountedElementAccessArgument(node)) ||
|
||||
(ts.isIdentifier(node) && node.text === SECOND_SENDER)
|
||||
) {
|
||||
references += 1
|
||||
}
|
||||
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
||||
references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0
|
||||
}
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require')) &&
|
||||
targetsPortModule(path, node.arguments[0])
|
||||
) {
|
||||
references += 1
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(parse(path, source))
|
||||
return references
|
||||
}
|
||||
|
||||
const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [
|
||||
...UNVALIDATED_RPC_REQUEST_PORT_OWNERS,
|
||||
...UNVALIDATED_RPC_REQUEST_PORT_PENDING
|
||||
]
|
||||
|
||||
const scanned = scannedRoots
|
||||
.flatMap(sourceFiles)
|
||||
.filter((path) => sourceExtensions.has(extname(path)))
|
||||
.filter((path) => !/\.test\.tsx?$/.test(path))
|
||||
.map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
|
||||
.filter((file) => !SELF_FILES.has(file))
|
||||
|
||||
const observed = new Map(
|
||||
scanned
|
||||
.map(
|
||||
(file) =>
|
||||
[
|
||||
file,
|
||||
rawRequestPortReferences(
|
||||
join(mobileRoot, file),
|
||||
readFileSync(join(mobileRoot, file), 'utf8')
|
||||
)
|
||||
] as const
|
||||
)
|
||||
.filter(([, references]) => references > 0)
|
||||
)
|
||||
|
||||
describe('unvalidated RPC request port boundary', () => {
|
||||
const probe = join(mobileRoot, 'src', 'transport', 'probe.ts')
|
||||
|
||||
it('counts every shape that reaches the port', () => {
|
||||
expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, "type A = Pick<RpcClient, 'sendRequest'>")).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1)
|
||||
expect(
|
||||
rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")')
|
||||
).toBe(1)
|
||||
expect(
|
||||
rawRequestPortReferences(
|
||||
probe,
|
||||
"import { sendSingleFlightRequest } from './request-single-flight'"
|
||||
)
|
||||
).toBe(1)
|
||||
expect(
|
||||
rawRequestPortReferences(
|
||||
probe,
|
||||
"import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'"
|
||||
)
|
||||
).toBe(1)
|
||||
expect(
|
||||
rawRequestPortReferences(
|
||||
probe,
|
||||
"export type { SendRequestOptions } from './unvalidated-rpc-request-port'"
|
||||
)
|
||||
).toBe(1)
|
||||
expect(
|
||||
rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')")
|
||||
).toBe(1)
|
||||
expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2)
|
||||
})
|
||||
|
||||
it('does not count prose or an unrelated sender', () => {
|
||||
expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0)
|
||||
expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0)
|
||||
expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0)
|
||||
expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0)
|
||||
expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0)
|
||||
})
|
||||
|
||||
it('scans a plausible number of files', () => {
|
||||
// A broken root or extension filter would make every check below vacuously pass.
|
||||
expect(scanned.length).toBeGreaterThan(400)
|
||||
expect(observed.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('lists each file once', () => {
|
||||
const seen = inventory.map((entry) => entry.file)
|
||||
expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([])
|
||||
})
|
||||
|
||||
it('has no unlisted file reaching the raw request port', () => {
|
||||
const listed = new Set(inventory.map((entry) => entry.file))
|
||||
const unlisted = [...observed.keys()].filter((file) => !listed.has(file))
|
||||
expect(
|
||||
unlisted,
|
||||
'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('has no stale inventory entry', () => {
|
||||
const stale = inventory.filter((entry) => !observed.has(entry.file))
|
||||
expect(
|
||||
stale.map((entry) => entry.file),
|
||||
'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('has no inventory entry whose file gained references', () => {
|
||||
const grown = inventory
|
||||
.filter((entry) => (observed.get(entry.file) ?? 0) > entry.references)
|
||||
.map(
|
||||
(entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}`
|
||||
)
|
||||
expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a count that has fallen so the entry can be lowered', () => {
|
||||
const overstated = inventory
|
||||
.filter(
|
||||
(entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references
|
||||
)
|
||||
.map(
|
||||
(entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}`
|
||||
)
|
||||
expect(
|
||||
overstated,
|
||||
'Fewer references than listed — lower the count so the ratchet holds.'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Every file that still reaches mobile's raw RPC request port, held as data.
|
||||
*
|
||||
* A reference is any direct reach for the port: a `.sendRequest` access or declaration, a
|
||||
* `'sendRequest'` selector such as `Pick<RpcClient, 'sendRequest'>`, a call to the coalescing
|
||||
* second sender `sendSingleFlightRequest`, or an import of unvalidated-rpc-request-port.ts.
|
||||
* The count is per file and is a ceiling, not a target: unvalidated-rpc-request-port-boundary.test.ts
|
||||
* fails on a file that is not listed, on a listed file that no longer reaches the port, and on a
|
||||
* listed file whose count went up. Both lists only shrink.
|
||||
*
|
||||
* The owners are permanent — they implement, route or validate the port. The pending list is the
|
||||
* step-4 migration backlog and shares one reason, stated once here instead of 144 times:
|
||||
* the call site predates the typed contract and still picks its own method string, its own
|
||||
* acceptance rule and its own decoding. Replacing one with an RpcOperation deletes its line.
|
||||
*/
|
||||
export type UnvalidatedRpcRequestPortEntry = {
|
||||
readonly file: string
|
||||
readonly references: number
|
||||
}
|
||||
|
||||
/** Modules whose job is the port. These do not shrink to zero. */
|
||||
export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [
|
||||
// Implements the port over the device-to-host websocket.
|
||||
{ file: 'src/transport/direct-rpc-client.ts', references: 3 },
|
||||
// Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests.
|
||||
{ file: 'src/transport/mobile-endpoint-supervisor-test-fakes.ts', references: 2 },
|
||||
// Implements the port over a relay channel.
|
||||
{ file: 'src/transport/mobile-relay-physical-client.ts', references: 2 },
|
||||
// Supplies the port for one relay session.
|
||||
{ file: 'src/transport/mobile-relay-rpc-session.ts', references: 1 },
|
||||
// A second raw sender: string method in, unread envelope out. Its callers are fenced too.
|
||||
{ file: 'src/transport/request-single-flight.ts', references: 3 },
|
||||
// Owns connect-wait, timeout and replay bookkeeping for every raw request.
|
||||
{ file: 'src/transport/rpc-client-request-tracker.ts', references: 1 },
|
||||
// Composes the port into RpcClient, which is why every holder of a client still carries it.
|
||||
{ file: 'src/transport/rpc-client.ts', references: 2 },
|
||||
// The typed boundary itself — the one module that turns a reply into a declared type.
|
||||
{ file: 'src/transport/rpc-operation.ts', references: 2 },
|
||||
// Forwards the port across a physical-client cutover.
|
||||
{ file: 'src/transport/stable-logical-rpc-client.ts', references: 2 }
|
||||
]
|
||||
|
||||
/** Call sites awaiting migration to a typed operation. Grouped by the feature area that owns them. */
|
||||
export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcRequestPortEntry[] = [
|
||||
// app/h/[hostId]/ — Expo route screens
|
||||
{ file: 'app/h/[hostId]/accounts.tsx', references: 2 },
|
||||
|
||||
// app/ — Expo route screens
|
||||
{ file: 'app/terminal-settings.tsx', references: 3 },
|
||||
|
||||
// src/agent-history/ — agent history loads
|
||||
{ file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 },
|
||||
{ file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 },
|
||||
|
||||
// src/browser/ — hosted browser control
|
||||
{ file: 'src/browser/use-mobile-browser-commands.ts', references: 5 },
|
||||
{ file: 'src/browser/use-mobile-browser-request.ts', references: 1 },
|
||||
|
||||
// src/components/ — shared widgets that fetch their own data
|
||||
{ file: 'src/components/codex-reset-credit-capability.ts', references: 2 },
|
||||
{ file: 'src/components/codex-reset-credit.ts', references: 3 },
|
||||
{ file: 'src/components/use-new-workspace-create-submit.ts', references: 1 },
|
||||
{ file: 'src/components/use-new-workspace-execution-target.ts', references: 4 },
|
||||
{ file: 'src/components/use-new-workspace-repositories.ts', references: 1 },
|
||||
{ file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 },
|
||||
{ file: 'src/components/use-new-workspace-setup-script.ts', references: 1 },
|
||||
|
||||
// src/dictation/ — dictation session control
|
||||
{ file: 'src/dictation/mobile-dictation-setup.ts', references: 10 },
|
||||
|
||||
// src/files/ — file read, write and preview
|
||||
{ file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 },
|
||||
{ file: 'src/files/mobile-file-preview-request.ts', references: 6 },
|
||||
{ file: 'src/files/mobile-file-tab-doc.ts', references: 4 },
|
||||
{ file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 },
|
||||
{ file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 },
|
||||
|
||||
// src/home/ — home screen host reads
|
||||
{ file: 'src/home/mobile-home-host-requests.ts', references: 6 },
|
||||
|
||||
// src/hooks/ — cross-screen data hooks
|
||||
{ file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 },
|
||||
{ file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 },
|
||||
{ file: 'src/hooks/use-mobile-dictation.ts', references: 4 },
|
||||
|
||||
// src/host-screen/ — host screen catalog and actions
|
||||
{ file: 'src/host-screen/host-screen-overlays.tsx', references: 1 },
|
||||
{ file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 },
|
||||
{ file: 'src/host-screen/use-host-view-settings.ts', references: 2 },
|
||||
{ file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 },
|
||||
|
||||
// src/notifications/ — push registration and delivery
|
||||
{ file: 'src/notifications/mobile-notifications.ts', references: 1 },
|
||||
{ file: 'src/notifications/push-dismissal-reconciliation.ts', references: 2 },
|
||||
{ file: 'src/notifications/push-registration.ts', references: 3 },
|
||||
|
||||
// src/session/ — session screen: chat, diff review, PR actions, tabs
|
||||
{ file: 'src/session/ai-vault-resume-launch.ts', references: 3 },
|
||||
{ file: 'src/session/ai-vault-resume-preparation.ts', references: 2 },
|
||||
{ file: 'src/session/github-pr-mutations.ts', references: 16 },
|
||||
{ file: 'src/session/github-pr-rpc.ts', references: 9 },
|
||||
{ file: 'src/session/mobile-clipboard-image.ts', references: 7 },
|
||||
{ file: 'src/session/mobile-diff-review-loaders.ts', references: 5 },
|
||||
{ file: 'src/session/mobile-file-tap-open.ts', references: 3 },
|
||||
{ file: 'src/session/mobile-image-attachment.ts', references: 2 },
|
||||
{ file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 },
|
||||
{ file: 'src/session/mobile-native-chat-image-send.ts', references: 2 },
|
||||
{ file: 'src/session/mobile-native-chat-send.ts', references: 2 },
|
||||
{ file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 },
|
||||
{ file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 },
|
||||
{ file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 },
|
||||
{ file: 'src/session/mobile-session-tab-activation.ts', references: 3 },
|
||||
{ file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 },
|
||||
{ file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 },
|
||||
{ file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 },
|
||||
{ file: 'src/session/pr-ai-triage-launch.ts', references: 3 },
|
||||
{ file: 'src/session/use-live-worktree-name.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-diff-review-interactions.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-diff-review-send-actions.ts', references: 3 },
|
||||
{ file: 'src/session/use-mobile-file-tap-handlers.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-native-chat-file-search.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-native-chat-session.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-pr-actions.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-pr-title-action.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-session-close-actions.ts', references: 3 },
|
||||
{ file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 },
|
||||
{ file: 'src/session/use-mobile-session-diff-comments.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-document-readers.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-markdown-actions.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-session-startup.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-terminal-create-actions.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-terminal-input.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-terminal-list.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 },
|
||||
{ file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 },
|
||||
{ file: 'src/session/use-mobile-terminal-paste.ts', references: 1 },
|
||||
{ file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 },
|
||||
{ file: 'src/session/use-quick-commands.ts', references: 2 },
|
||||
|
||||
// src/settings/ — settings screen actions
|
||||
{ file: 'src/settings/native-voice-settings-operations.ts', references: 1 },
|
||||
|
||||
// src/settings/ — notification display probe
|
||||
{ file: 'src/settings/notification-display-test.tsx', references: 1 },
|
||||
|
||||
// src/source-control/ — source control: review, commit, branch
|
||||
{ file: 'src/source-control/mobile-branch-base-ref.ts', references: 3 },
|
||||
{ file: 'src/source-control/mobile-commit-message-ai.ts', references: 4 },
|
||||
{ file: 'src/source-control/mobile-git-history.ts', references: 2 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-create-intent-runner.ts', references: 1 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-create-intent.ts', references: 3 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-git-preparation.ts', references: 6 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-remote-prerequisite.ts', references: 1 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-service.ts', references: 8 },
|
||||
{ file: 'src/source-control/mobile-pr-link.ts', references: 8 },
|
||||
{ file: 'src/source-control/MobileGitHistoryList.tsx', references: 1 },
|
||||
{ file: 'src/source-control/reveal-mobile-source-control-session-diff.ts', references: 2 },
|
||||
{ file: 'src/source-control/use-mobile-git-requests.ts', references: 1 },
|
||||
{ file: 'src/source-control/use-mobile-source-control-loaders.ts', references: 2 },
|
||||
{ file: 'src/source-control/use-mobile-source-control-openers.ts', references: 3 },
|
||||
|
||||
// src/tasks/ — task lists, filters and mutations
|
||||
{ file: 'src/tasks/composer-source-base-resolve.ts', references: 2 },
|
||||
{ file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 },
|
||||
{ file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 },
|
||||
{ file: 'src/tasks/setup-hook-trust.ts', references: 1 },
|
||||
{ file: 'src/tasks/smart-source-paste-intent.ts', references: 4 },
|
||||
{ file: 'src/tasks/smart-source-search-requests.ts', references: 5 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 },
|
||||
{ file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 },
|
||||
{ file: 'src/tasks/worktree-create-capability.ts', references: 1 },
|
||||
{ file: 'src/tasks/worktree-create-retry.ts', references: 1 },
|
||||
|
||||
// src/terminal/ — terminal input, viewport and queries
|
||||
{ file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 },
|
||||
{ file: 'src/terminal/terminal-live-accessory-raw-send.ts', references: 2 },
|
||||
{ file: 'src/terminal/terminal-viewport-refit.ts', references: 1 },
|
||||
{ file: 'src/terminal/worker-terminal-takeover-report.ts', references: 2 },
|
||||
|
||||
// src/transport/ — pairing, endpoint probing and capability reads
|
||||
{ file: 'src/transport/host-status-gates.ts', references: 1 },
|
||||
{ file: 'src/transport/mobile-relay-credential-rotation.ts', references: 2 },
|
||||
{ file: 'src/transport/mobile-relay-direct-upgrade.ts', references: 2 },
|
||||
{ file: 'src/transport/mobile-relay-pairing-recovery.ts', references: 2 },
|
||||
{ file: 'src/transport/mobile-runtime-capability-negotiation.ts', references: 2 },
|
||||
{ file: 'src/transport/pairing-candidate-race.ts', references: 1 },
|
||||
{ file: 'src/transport/pairing-relay-candidate.ts', references: 4 },
|
||||
{ file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 },
|
||||
{ file: 'src/transport/runtime-capability-probe.ts', references: 2 },
|
||||
|
||||
// src/worktree/ — worktree activation and resume
|
||||
{ file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 },
|
||||
{ file: 'src/worktree/use-retired-worktree-names.ts', references: 1 },
|
||||
{ file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 }
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
// The raw request port, kept in its own module so that reaching it is a visible act.
|
||||
//
|
||||
// Nothing on this path is checked against the host contract: `method` is an unconstrained
|
||||
// string, `params` is `unknown`, and the reply's `result` stays `unknown`. A value that came
|
||||
// back through here has been parsed as JSON and nothing more, so it is NOT validated and must
|
||||
// not be annotated as though it were. The typed boundary — defineRpcOperation and the send
|
||||
// helpers in rpc-operation.ts — is the only path that turns a reply into a declared type, and
|
||||
// rpc-operation.ts is the only module here that should be importing this one for that purpose.
|
||||
//
|
||||
// Every other file that still reaches this port is inventoried in
|
||||
// unvalidated-rpc-request-port-inventory.ts and fenced by
|
||||
// unvalidated-rpc-request-port-boundary.test.ts. That list only shrinks.
|
||||
|
||||
export type SendRequestOptions = {
|
||||
timeoutMs?: number
|
||||
/** Include the connect wait in the caller's timeout budget. */
|
||||
budgetSpansConnect?: boolean
|
||||
/** Reject instead of replaying the request after reconnect. */
|
||||
failWhenDisconnected?: boolean
|
||||
}
|
||||
|
||||
/** Unvalidated: an arbitrary method name in, an unread envelope out. */
|
||||
export type UnvalidatedRpcRequestPort = {
|
||||
sendRequest: (
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: SendRequestOptions
|
||||
) => Promise<RpcResponse>
|
||||
}
|
||||
+4
-3
@@ -1159,9 +1159,10 @@ export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [
|
||||
|
||||
export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
|
||||
|
||||
// Why: z.output is the post-parse shape the handler receives. z.input is not a
|
||||
// send-side type here — requiredString is z.unknown().transform(...), so its input
|
||||
// admits any value and loses optional/default semantics.
|
||||
// Why: z.output is the post-parse shape the handler receives, which is not what a
|
||||
// client may send — a .default() field reads as required. z.input is not the answer
|
||||
// either: requiredString is z.unknown().transform(...), so its input admits any value.
|
||||
// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map.
|
||||
export type RpcParams<Method extends RpcMethodName> =
|
||||
(typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
|
||||
? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { z } from 'zod'
|
||||
import type { RPC_PARAMS_BY_METHOD, RpcMethodName } from './rpc-params-catalog.generated'
|
||||
|
||||
// Why this exists: neither of zod's two inferred types describes an outgoing request.
|
||||
// z.output is what the handler receives *after* parsing, so a `.default(x)` field reads as
|
||||
// required and a sender that legitimately omits it fails to typecheck. z.input is worse here
|
||||
// — the params builders parse with z.unknown() so a hostile client cannot crash the
|
||||
// dispatcher, which collapses every requiredString/OptionalString field to `unknown`.
|
||||
//
|
||||
// So take each channel where it is honest: key optionality from zod's own `optin` marker
|
||||
// (the z.input rule, which is the one that understands .default and .optional), and value
|
||||
// types from z.output (the post-coercion contract the builders declare in their pipe target).
|
||||
// Derived from the generated catalog, so it cannot drift from the dispatcher.
|
||||
//
|
||||
// Type-level only. Never import the schema *values* into a client: requiredString is
|
||||
// z.unknown().transform(...), so a client-side parse coerces a non-string to '' instead of
|
||||
// rejecting it, silently changing the bytes on the wire.
|
||||
|
||||
type Prettify<T> = { [K in keyof T]: T[K] } & {}
|
||||
|
||||
/** zod's own input-side key-optionality rule, copied from $InferObjectInput. */
|
||||
type SendOptionalSchema = { _zod: { optin: 'optional' | 'defaulted' } }
|
||||
|
||||
type SendShape<Shape> = Prettify<
|
||||
{
|
||||
-readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? never : K]: RpcSendInput<
|
||||
Shape[K]
|
||||
>
|
||||
} & {
|
||||
-readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? K : never]?: RpcSendInput<
|
||||
Shape[K]
|
||||
>
|
||||
}
|
||||
>
|
||||
|
||||
/**
|
||||
* The value a sender may put on the wire for one schema. Wrappers not listed here (record,
|
||||
* tuple, lazy, intersection) fall through to z.output, which is what shipped before.
|
||||
*/
|
||||
export type RpcSendInput<Schema> =
|
||||
Schema extends z.ZodOptional<infer Inner>
|
||||
? RpcSendInput<Inner> | undefined
|
||||
: Schema extends z.ZodDefault<infer Inner>
|
||||
? RpcSendInput<Inner> | undefined
|
||||
: Schema extends z.ZodPrefault<infer Inner>
|
||||
? RpcSendInput<Inner> | undefined
|
||||
: Schema extends z.ZodNullable<infer Inner>
|
||||
? RpcSendInput<Inner> | null
|
||||
: Schema extends z.ZodArray<infer Element>
|
||||
? RpcSendInput<Element>[]
|
||||
: // ZodObject is the only schema carrying a `shape`, and matching on it keeps
|
||||
// .strict()/.extend()/.superRefine() results in this branch.
|
||||
Schema extends { shape: infer Shape }
|
||||
? keyof Shape extends never
|
||||
? // Mirrors $InferObjectOutput: a no-field object admits no properties.
|
||||
Record<string, never>
|
||||
: SendShape<Shape>
|
||||
: // ZodDiscriminatedUnion extends ZodUnion, so both land here.
|
||||
Schema extends z.ZodUnion<infer Options>
|
||||
? RpcSendInput<Options[number]>
|
||||
: Schema extends z.ZodType
|
||||
? z.output<Schema>
|
||||
: never
|
||||
|
||||
/** The params a client may send for `Method`; `void` for the methods that take none. */
|
||||
export type RpcSendParams<Method extends RpcMethodName> =
|
||||
(typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
|
||||
? RpcSendInput<(typeof RPC_PARAMS_BY_METHOD)[Method]>
|
||||
: void
|
||||
Reference in New Issue
Block a user