mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* fix(mobile): move the last six reply-enum pins where tsc looks mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>` coverage record in a schema test is never typechecked: the two that existed (SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four closed enums beside them had only a doc citation of the host type. Each arm list moves into its schema module as hostUnionArms<Union>(), which #21269 introduced for the same reason, and each test iterates the exported list instead of holding its own copy: - SSH_CONNECTION_STATUS to SshConnectionStatus - PROJECT_OWNER_TYPE to GitHubProjectOwnerType - DETAIL_FILE_STATUS to GitHubPRFile['status'] - PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal arms of MobilePushTestResult and MobilePushRegisterResult - SETUP_RUN_POLICIES to SetupRunPolicy openEnum's parameter widens from a non-empty tuple to `readonly string[]` so a hostUnionArms list can feed it. z.enum already accepts the same, so the tuple constraint only excluded callers zod itself takes; behaviour unchanged. Twelve mutations prove the pins: dropping one arm and adding a bogus one each fail mobile tsc in all six places. Zero goldens move, the schemas' behaviour being unchanged, and the 21 recording suites pass at the existing baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fix the type errors in eighteen test files Found by typechecking the tests for the first time (see the config that follows). All mechanical, none weakens a product type: - 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils where act wants void, so each becomes a block. The async ones await only a genuinely promise-returning call, so no extra microtask tick is introduced. - Four fixtures were stale against a product type that gained a required member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError, the branch-compare summary's errorMessage, and SessionOptionDescriptor's transport, which #20884 added precisely so a producer could not inherit the wrong lane's rendering by omission. - `getLastConnectedAt` on the shared relay fake was typed `() => null`, which refused the timestamp two escalation suites assign to it. - Two holders used before assignment take `!`, one `advance!.kind === ...` becomes `advance?.kind`, one widened status arm takes `as const`, and the Expo notification fixture keeps `data` required because the dismissal cases assign through it. 631 test files pass, 6222 tests, unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the test files, on a ratchet mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the release bundle, and vitest transpiles without checking types. Nothing had ever typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written in one proved nothing and why 144 of the 630 test files had drifted. tsconfig.test.json is that program with the tests put back, behind `typecheck:tests`. Four files stay out: they import the desktop main process or src/shared/child-process, which are written against @types/node, and this program's libs are React Native's, where setTimeout answers a number rather than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the desktop rather than about mobile; vitest runs those four under Node, which is where they belong. The CI gate is a ratchet rather than the raw typecheck, modelled on check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that set and fails when a file that checks today stops checking, or when a baseline entry starts checking and was not pruned. The list may only shrink. Why not zero: 180 of the remaining 510 errors are one seam — tests locate mocked react-native components by string name, which `ElementType` does not admit — and closing it means either 180 casts or a global JSX declaration for the mocked names. That is a design decision, not a mechanical fix, so it is left for a follow-up rather than made here. The rest are smaller clusters of the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing, and createElement props fixtures. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-recorder): correct the corpus counts and the salvage claim The oracle section still quoted the corpus as 368 scenarios and 727 goldens; it is 393 and 778, and the three replay suites report 781 tests. Each number now names the command that measures it. "No golden carries one" was the load-bearing error: 44 goldens carry a recorded `reply-salvage` today, starting with the push-test unknown-reason scenario #21176 added for exactly that purpose. The paragraph claimed the observation pins an absence when on those families it pins a recorded drop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the tests-typecheck ratchet's parser The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail under an error. Counting those as filenames would write unparseable entries into the baseline and leave the gate unprunable, so the parser is pinned on that shape as well as on the added/stale diff. Written against the gate itself: it flagged this file before the directive it carried was removed, which is the end-to-end proof the spawn half works. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): await the timer advances the act() rewrite dropped Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a braced body left the returned promise floating at 27 sites, so the advance was no longer ordered before the assertions that follow it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unshadow MobileHostCard's .tsx suite A wildcard `include` keeps only the higher-priority extension, so MobileHostCard.test.tsx sat outside every tsc program while MobileHostCard.test.ts existed beside it. Its one error is the same react-test-renderer seam its sibling is baselined for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census every test file into the typecheck program The ratchet diffs only files that error, so a test excluded from tsconfig.test.json or shadowed by a sibling extension left the gate silently. Every *.test.ts(x) on disk must now be in the program or named in TESTS_OUTSIDE_PROGRAM with its reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): make the enum helpers refuse the ways they can prove nothing openEnum takes a `const` T so a bare literal keeps its arms rather than widening to string. hostUnionArms blocks inference of U with NoInfer and defaults it to never, so a call that omits the host union — where the record would only pin itself — no longer compiles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the census and correct the baseline count Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the push fixture cast its SAFETY rationale Widening the pre-existing cast made the changed-code gate attribute it as a new finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the push fixtures as typed notifications Replaces the `as unknown as` cast with Expo's own types, filling FirebaseRemoteMessage and its notification once in two builders, and passes the data payload in rather than mutating through an optional member. Typing the fixture showed one assertion comparing the scheduled content against the whole arriving content, which only held while the cast let the fixture omit the two members the presenter drops; it now names the four members the presenter forwards. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the grouped-question advance read non-optional `advance?.kind` let an absent advance take the null-draft branch instead of failing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the tests-typecheck ratchet on Windows Spawns tsc's JS entry on this Node instead of the node_modules/.bin shim, which is a POSIX shell script that Windows resolves to tsc.CMD and then appends .exe to. Parsed paths are normalised to POSIX so a Windows run does not read every baseline entry as both stale and added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the ratchet's @ts-nocheck hole and read tsc once tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed" with one line, pruned, and never checked again; the census now names any program test file whose leading comment carries the directive. `--noEmit --listFiles` answers both questions in one pass, so the gate spawns tsc once rather than twice. Corrects the two stale counts, and states hostUnionArms' real reason for living in the schema module now that tests are typechecked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { z } from 'zod'
|
|
import type { GitHubProjectOwnerType } from '../../../src/shared/github/project-types'
|
|
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
|
|
import {
|
|
assignableUserListSchema,
|
|
detailCheckListSchema,
|
|
detailCommentListSchema,
|
|
detailFileListSchema,
|
|
reviewSummaryListSchema
|
|
} from './task-provider-entity-reply-schema'
|
|
|
|
// The sixteen `github.project.*` replies the Projects board reads. Every one of them is an
|
|
// accepted result carrying its own `{ ok, error }` envelope, published by
|
|
// src/main/runtime/rpc/methods/github-project-methods.ts and typed in
|
|
// src/shared/github/project-result-types.ts.
|
|
//
|
|
// Two shapes appear here, and which one a reply gets is decided by its consumer, not by the host:
|
|
//
|
|
// - a discriminated union, where the consumer reads a member off BOTH arms without a guard
|
|
// (`result.error.message` on the refusal arm and the payload on the success arm). `ok` is
|
|
// load-bearing there, so an envelope without it is an incompatible reply rather than the
|
|
// property-read TypeError main threw;
|
|
// - a flat passthrough object, where the consumer guards every member it reads
|
|
// (`result.error?.message ?? '...'`, `result.labels ?? []`). Nothing is required there beyond
|
|
// the container, because requiring a member the consumer already defaults would refuse a reply
|
|
// main rendered.
|
|
//
|
|
// Required members are only ever ones a recorded golden shows the host sending AND a consumer
|
|
// reads unguarded. Nothing deeper is declared: the project table's rows, for instance, have no
|
|
// recorded row to check a deeper requirement against, so requiring one would be a claim about the
|
|
// wire that this corpus cannot support.
|
|
|
|
/**
|
|
* `owner`/`ownerType`/`number` compose the persisted project key, so every row carries them.
|
|
*
|
|
* CLOSED, and the one enum here that is. The two arms are GitHubProjectOwnerType verbatim
|
|
* (src/shared/github/project-types.ts:8), which is the type the handler's own params and results
|
|
* are declared in — not mobile's restatement of it. It stays closed because a decoded `ownerType`
|
|
* is echoed straight back into the params of the next project call
|
|
* (mobile-tasks-github-project-pickers.tsx:227, use-mobile-tasks-project-loading-actions.tsx:75,
|
|
* :110, :269), and remote-wire-compatibility.md rule 4 forbids letting a fallback shape a param
|
|
* the host reads back. An unknown arm therefore drops its row rather than inventing an owner type.
|
|
* The corpus exercises `'organization'` (`tk-project-board-load`); `'user'` is unexercised but
|
|
* inside the set, so no reply can drop on it.
|
|
*/
|
|
// Pinned to the host's own union through hostUnionArms: an arm added or dropped host-side fails tsc.
|
|
export const PROJECT_OWNER_TYPE = hostUnionArms<GitHubProjectOwnerType>({
|
|
organization: true,
|
|
user: true
|
|
})
|
|
|
|
const projectMessage = (name: string) => salvagedOptional(name, z.string())
|
|
const projectCount = (name: string) => salvagedOptional(name, z.number())
|
|
|
|
/** The refusal arm's message, read unguarded as `result.error.message`. */
|
|
const requiredProjectError = z
|
|
.looseObject({ message: projectMessage('message') })
|
|
.transform((error) => ({
|
|
...error,
|
|
message: error.message ?? ''
|
|
}))
|
|
|
|
/** The refusal arm's message where the consumer already spells `?? 'Failed to …'`. */
|
|
const optionalProjectError = salvagedOptional(
|
|
'error',
|
|
z.looseObject({ message: projectMessage('message') })
|
|
)
|
|
|
|
/** `ok` where the consumer tests it rather than switching on it: absent still reads as refused. */
|
|
const optionalOk = salvagedOptional('ok', z.boolean())
|
|
|
|
/**
|
|
* One accessible project.
|
|
*
|
|
* `owner` is required because githubProjectIdentityKey calls `.toLowerCase()` on it
|
|
* (src/shared/github/project-identity.ts:13) for every row the picker stores or compares, and
|
|
* `ownerType`/`number` are the rest of that key. `title` is required for the same reason one step
|
|
* later: the picker's search spells `project.title.toLowerCase()` with no guard
|
|
* (use-mobile-tasks-provider-view-projection.tsx:213), it is non-optional on GitHubProjectSummary
|
|
* (src/shared/github/project-types.ts:200-209), and the recorded reply carries it. A row without
|
|
* any of the four is dropped rather than failing the list, which keeps the banner and the
|
|
* remaining projects.
|
|
*
|
|
* `id`, `url` and `source` stay undeclared: the recorded reply (`tk-project-board-load`,
|
|
* `github.project.listAccessible#1`) carries none of them, so requiring what the shared type
|
|
* declares would refuse main's own fixture.
|
|
*/
|
|
const projectSummary = z.looseObject({
|
|
owner: z.string(),
|
|
ownerType: z.enum(PROJECT_OWNER_TYPE),
|
|
number: z.number(),
|
|
title: z.string(),
|
|
host: projectMessage('host')
|
|
})
|
|
|
|
/**
|
|
* The accessible-project list.
|
|
*
|
|
* `projects` is required on the success arm: use-mobile-tasks-project-loading-actions.tsx:62 hands
|
|
* it straight to `setGithubProjects`, and `partialFailures` is not, because :64 spells `?? []`.
|
|
* `error.message` is required on the refusal arm because :59 throws it unguarded.
|
|
*/
|
|
export const taskProjectAccessibleListSchema = z.union([
|
|
z.looseObject({
|
|
ok: z.literal(true),
|
|
projects: salvagingArray(projectSummary),
|
|
partialFailures: salvagedOptional(
|
|
'partialFailures',
|
|
salvagingArray(
|
|
z.looseObject({ owner: projectMessage('owner'), message: projectMessage('message') })
|
|
)
|
|
)
|
|
}),
|
|
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
|
|
])
|
|
|
|
/**
|
|
* A project's views.
|
|
*
|
|
* `views` is required: :84 publishes it and :85 returns it, and :191/:196/:210/:220 run `find` and
|
|
* `filter` over the same array with no guard. A view needs the `id` the selection is committed
|
|
* under (:225 → githubProjectSettings.lastViewByProject), so a row without one drops.
|
|
*
|
|
* `layout` is a plain string, not an enum. Every reader is an equality test against
|
|
* `'TABLE_LAYOUT'` (:196/:204/:210/:220), so a layout arm this build has not heard of already
|
|
* reads as "not supported" — closing the set would instead drop the row and change the count the
|
|
* "no supported views" copy is decided by. That is remote-wire-compatibility.md rule 4.
|
|
*/
|
|
export const taskProjectViewListSchema = z.union([
|
|
z.looseObject({
|
|
ok: z.literal(true),
|
|
views: salvagingArray(
|
|
z.looseObject({
|
|
id: z.string(),
|
|
number: projectCount('number'),
|
|
name: projectMessage('name'),
|
|
layout: projectMessage('layout')
|
|
})
|
|
)
|
|
}),
|
|
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
|
|
])
|
|
|
|
/**
|
|
* The board table.
|
|
*
|
|
* `data` and `data.selectedView` are required because :126 reads `data.selectedView.filter` and
|
|
* :128-:137 read four more members off it, all unguarded — a reply without the container was a
|
|
* property read on undefined. `project.id` is required for the same reason at
|
|
* use-mobile-tasks-project-metadata-actions.tsx:160/:172, which sends it as `projectId` on every field
|
|
* mutation.
|
|
*
|
|
* Nothing inside `selectedView` or `rows` is required. The recorded table
|
|
* (`tk-project-board-load`, `github.project.viewTable#1`) carries a `project` with no `owner`,
|
|
* `ownerType` or `url` and an empty `rows`, so this corpus has no evidence for a deeper claim.
|
|
*/
|
|
export const taskProjectViewTableSchema = z.union([
|
|
z.looseObject({
|
|
ok: z.literal(true),
|
|
data: z.looseObject({
|
|
project: z.looseObject({ id: z.string() }),
|
|
selectedView: z.looseObject({
|
|
id: projectMessage('id'),
|
|
number: projectCount('number'),
|
|
name: projectMessage('name'),
|
|
layout: projectMessage('layout'),
|
|
filter: projectMessage('filter')
|
|
}),
|
|
rows: salvagedOptional('rows', salvagingArray(z.looseObject({ id: z.string() })))
|
|
})
|
|
}),
|
|
z.looseObject({
|
|
ok: z.literal(false),
|
|
error: requiredProjectError,
|
|
totalCount: projectCount('totalCount')
|
|
})
|
|
])
|
|
|
|
/**
|
|
* A pasted project URL or `owner/number`, resolved.
|
|
*
|
|
* `owner`, `ownerType` and `number` are required:
|
|
* use-mobile-tasks-project-loading-actions.tsx:267-:272 forward all three into
|
|
* `selectGitHubProject`, which puts them in the key and in the next `github.project.listViews`
|
|
* params. `ownerType` is therefore a CLOSED enum with no fallback — it is echoed into a param, and
|
|
* remote-wire-compatibility.md rule 4 forbids a reply-schema fallback from shaping one. The arm
|
|
* set is genuinely closed host-side: project-view-listing.ts:23 answers `validation_error` for any
|
|
* other value, so degrading an unknown arm would only put a value the host refuses on the wire.
|
|
*
|
|
* `title` is optional because no mobile consumer reads it. `host` is optional because
|
|
* use-mobile-tasks-project-loading-actions.tsx:271 coalesces it (`result.host ?? parsed.host`),
|
|
* and `viewNumber` because :190 tests `typeof options.viewNumber === 'number'` before using it.
|
|
*/
|
|
export const taskProjectRefSchema = z.union([
|
|
z.looseObject({
|
|
ok: z.literal(true),
|
|
owner: z.string(),
|
|
ownerType: z.enum(PROJECT_OWNER_TYPE),
|
|
number: z.number(),
|
|
title: projectMessage('title'),
|
|
host: projectMessage('host'),
|
|
viewNumber: projectCount('viewNumber')
|
|
}),
|
|
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
|
|
])
|
|
|
|
/**
|
|
* A board row's detail pane.
|
|
*
|
|
* `details` is required: use-mobile-tasks-project-detail-loading.tsx:110-:121 reads eleven members
|
|
* off it, each defaulted but the container itself never guarded. `error.message` is required
|
|
* because :102 throws it.
|
|
*
|
|
* `item.reviewDecision` is nullable AND optional and stays that way. :121 forwards it verbatim
|
|
* into `projectRowDetail`, where `null` ("reviewed, no decision") and absent ("this host does not
|
|
* report one") are different states — collapsing either into the other with a `??` here would be
|
|
* the null-collapse the session domain shipped and had caught two review rounds later.
|
|
*/
|
|
export const taskProjectRowDetailSchema = z.union([
|
|
z.looseObject({
|
|
ok: z.literal(true),
|
|
details: z.looseObject({
|
|
body: projectMessage('body'),
|
|
comments: salvagedOptional('comments', detailCommentListSchema),
|
|
item: salvagedOptional(
|
|
'item',
|
|
z.looseObject({
|
|
labels: salvagedOptional('labels', salvagingArray(z.string())),
|
|
reviewDecision: salvagedOptional('reviewDecision', z.string().nullable()),
|
|
reviewRequests: salvagedOptional('reviewRequests', assignableUserListSchema),
|
|
latestReviews: salvagedOptional('latestReviews', reviewSummaryListSchema)
|
|
})
|
|
),
|
|
assignees: salvagedOptional('assignees', salvagingArray(z.string())),
|
|
headSha: projectMessage('headSha'),
|
|
baseSha: projectMessage('baseSha'),
|
|
pullRequestId: projectMessage('pullRequestId'),
|
|
checks: salvagedOptional('checks', detailCheckListSchema),
|
|
files: salvagedOptional('files', detailFileListSchema)
|
|
})
|
|
}),
|
|
z.looseObject({ ok: z.literal(false), error: requiredProjectError })
|
|
])
|
|
|
|
/**
|
|
* The repo label list.
|
|
*
|
|
* Flat, not a union: use-mobile-tasks-project-metadata-loading.tsx:53 spells
|
|
* `result.error?.message ?? 'Failed to load labels'` and :55 spells `result.labels ?? []`, so
|
|
* every member is already defaulted and an envelope with no `ok` still reads as refused. What the
|
|
* schema adds is the container and the element type — a `labels` that is not an array of strings
|
|
* reached the label picker as rendered garbage.
|
|
*/
|
|
export const taskProjectLabelListSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
labels: salvagedOptional('labels', salvagingArray(z.string())),
|
|
error: optionalProjectError
|
|
})
|
|
|
|
/** The assignable-user list, guarded the same way at :105-:110. The row is
|
|
* `assignableUserListSchema`, the same entity the item sheet's reviewer picker decodes: `login` is
|
|
* what both key on, and `name`/`avatarUrl` keep their explicit `null`. */
|
|
export const taskProjectAssignableUserListSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
users: salvagedOptional('users', assignableUserListSchema),
|
|
error: optionalProjectError
|
|
})
|
|
|
|
/** The repo issue types, guarded the same way at :159-:164. `id` is the mutation's own param
|
|
* (use-mobile-tasks-project-metadata-actions.tsx:240), so a row without one cannot be applied. */
|
|
export const taskProjectIssueTypeListSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
types: salvagedOptional(
|
|
'types',
|
|
salvagingArray(z.looseObject({ id: z.string(), name: projectMessage('name') }))
|
|
),
|
|
error: optionalProjectError
|
|
})
|
|
|
|
/**
|
|
* The five board mutations whose reply is only a verdict: the issue and pull-request metadata
|
|
* writes, the issue-type write, and the field set/clear pair.
|
|
*
|
|
* Every consumer tests `result.ok === false` and then `result.error?.message ?? '…'`
|
|
* (use-mobile-tasks-project-metadata-actions.tsx:63/:182/:245 and
|
|
* use-mobile-tasks-project-workspace-comment-actions.tsx:142), so nothing but the container is
|
|
* required. The container is the change: `project.update-metadata`'s `b2` seed answers
|
|
* `result: null`, which main read as `null.ok` and #20563 left recorded as a TypeError. It is now
|
|
* named as an incompatible `github.project.updateIssueBySlug` reply instead.
|
|
*/
|
|
export const taskProjectMutationStatusSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
error: optionalProjectError
|
|
})
|
|
|
|
/**
|
|
* The added comment.
|
|
*
|
|
* `comment` is optional because :225 gates on it before appending. It carries `id` and `body`
|
|
* because the thread renderer keys and prints them, and because the recorded reply
|
|
* (`tk-project-row-comments-issue`) carries both — `id` as a NUMBER there, which is why the
|
|
* schema takes either rather than the string the mobile type leads with.
|
|
*/
|
|
export const taskProjectCommentWriteSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
comment: salvagedOptional(
|
|
'comment',
|
|
z.looseObject({
|
|
id: z.union([z.string(), z.number()]),
|
|
body: projectMessage('body'),
|
|
author: projectMessage('author'),
|
|
createdAt: projectMessage('createdAt')
|
|
})
|
|
),
|
|
error: optionalProjectError
|
|
})
|
|
|
|
/**
|
|
* The comment edit and delete verdicts.
|
|
*
|
|
* `error` is a string OR an envelope, because both consumers read it that way
|
|
* (use-mobile-tasks-project-workspace-comment-actions.tsx:271 and
|
|
* use-mobile-tasks-project-thread-reply-actions.tsx:62 both branch on `typeof result.error ===
|
|
* 'string'`). Declaring only the envelope would salvage the string away and replace a host message
|
|
* the user has always seen with this app's fallback copy.
|
|
*/
|
|
export const taskProjectCommentMutationSchema = z.looseObject({
|
|
ok: optionalOk,
|
|
error: salvagedOptional(
|
|
'error',
|
|
z.union([z.string(), z.looseObject({ message: projectMessage('message') })])
|
|
)
|
|
})
|